From c989ba6fd0cf9dad02206b9e5293957f942eba0c Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Fri, 2 Oct 2026 22:29:38 -0400 Subject: [PATCH] ci: run cross-platform suites and synthetic gate in parallel --- .github/workflows/ci.yml | 53 ++++++++++++++++---- docs/performance/synthetic-nest-validity.md | 18 +++++-- scripts/check-ci-results.sh | 15 ++++++ scripts/test_ci_results.py | 55 +++++++++++++++++++++ 4 files changed, 129 insertions(+), 12 deletions(-) create mode 100644 scripts/check-ci-results.sh create mode 100644 scripts/test_ci_results.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5066d29..bef3063 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,9 +9,19 @@ permissions: contents: read jobs: - tests: + unit-tests: + name: Unit tests (${{ matrix.project }}) runs-on: ubuntu-latest timeout-minutes: 30 + strategy: + fail-fast: false + max-parallel: 4 + matrix: + project: + - OpenNest.Tests + - OpenNest.Engine.Tests + - OpenNest.IO.Tests + - OpenNest.Server.Tests steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: @@ -19,16 +29,41 @@ jobs: - uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4 with: dotnet-version: '8.0.x' - - name: Run OpenNest.Tests - run: dotnet test OpenNest.Tests/OpenNest.Tests.csproj - - name: Run OpenNest.Engine.Tests - run: dotnet test OpenNest.Engine.Tests/OpenNest.Engine.Tests.csproj + - name: Run ${{ matrix.project }} + run: dotnet test "${{ matrix.project }}/${{ matrix.project }}.csproj" + + synthetic-nests: + name: Six synthetic Irregular nests + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + - uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4 + with: + dotnet-version: '8.0.x' + - name: Test fail-closed final CI check + run: python3 scripts/test_ci_results.py -v - name: Check six synthetic Irregular nests timeout-minutes: 6 run: | python3 scripts/test_check_synthetic_nests.py -v python3 scripts/check-synthetic-nests.py --parallel 2 - - name: Run OpenNest.IO.Tests - run: dotnet test OpenNest.IO.Tests/OpenNest.IO.Tests.csproj - - name: Run OpenNest.Server.Tests - run: dotnet test OpenNest.Server.Tests/OpenNest.Server.Tests.csproj + + # Preserve the existing check name for required-status consumers. + tests: + name: tests + needs: [unit-tests, synthetic-nests] + if: ${{ always() }} + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + - name: Require every test job to succeed + env: + UNIT_TEST_RESULT: ${{ needs.unit-tests.result }} + SYNTHETIC_RESULT: ${{ needs.synthetic-nests.result }} + run: bash scripts/check-ci-results.sh "$UNIT_TEST_RESULT" "$SYNTHETIC_RESULT" diff --git a/docs/performance/synthetic-nest-validity.md b/docs/performance/synthetic-nest-validity.md index 6f408a2..3017185 100644 --- a/docs/performance/synthetic-nest-validity.md +++ b/docs/performance/synthetic-nest-validity.md @@ -73,9 +73,21 @@ The unit checks use labeled synthetic CSV rows and a real watchdog process-tree probe. The end-to-end command uses actual benchmark results and the production independent layout validator. This gate supplements, never replaces, the existing geometry, contract, cancellation, sequencing, posting and cross-platform unit suites. -GitHub's cross-platform workflow runs both the checker tests and the six-fixture gate. -Windows runtime acceptance is separate; a Linux pass does not certify Windows tree -termination or desktop/shop interactions. +GitHub's cross-platform workflow runs both the checker tests and the six-fixture gate +in a separate job, concurrently with four independent unit-suite jobs (main, Engine, +IO and Server). Unit jobs retain the default Debug configuration and do not cancel +siblings on failure. The final `tests` check requires the entire unit matrix and +synthetic job to succeed; failed, cancelled or skipped dependencies cannot produce +a green final check. This preserves the existing check name without changing branch +protection or removing tests. `python3 scripts/test_ci_results.py -v` exercises the +same fail-closed aggregate command used by CI. The six-minute step limit and +`--parallel 2` synthetic-runner limit are unchanged. + +This fan-out removes inter-suite serialization, not individual geometry-test work. +It uses additional independent runners and repeats restore/build setup; actual wall +time depends on the slowest job and runner queueing. It is not a CPU speed claim or +a reason to skip correctness fixtures. Windows runtime acceptance is separate; a +Linux pass does not certify Windows tree termination or desktop/shop interactions. ## Validity is not speed diff --git a/scripts/check-ci-results.sh b/scripts/check-ci-results.sh new file mode 100644 index 0000000..09d07aa --- /dev/null +++ b/scripts/check-ci-results.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# The stable final CI check succeeds only when both required job groups succeeded. +set -euo pipefail + +if [[ $# -ne 2 ]]; then + printf 'Expected unit-test and synthetic-nest job results.\n' >&2 + exit 1 +fi + +if [[ "$1" != success || "$2" != success ]]; then + printf 'Required CI jobs did not both succeed (unit=%s, synthetic=%s).\n' "$1" "$2" >&2 + exit 1 +fi + +printf 'All required unit suites and the synthetic-nest gate passed.\n' diff --git a/scripts/test_ci_results.py b/scripts/test_ci_results.py new file mode 100644 index 0000000..5619a43 --- /dev/null +++ b/scripts/test_ci_results.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +"""Fail-closed tests for the exact command used by the final CI check.""" + +from pathlib import Path +import subprocess +import unittest + + +CHECK = Path(__file__).resolve().with_name("check-ci-results.sh") + + +class CIResultsTests(unittest.TestCase): + def check(self, *results): + return subprocess.run( + ["bash", str(CHECK), *results], + capture_output=True, + text=True, + timeout=5, + ) + + def test_both_required_jobs_successful(self): + self.assertEqual(self.check("success", "success").returncode, 0) + + def test_unit_matrix_failure_blocks_aggregate(self): + self.assertNotEqual(self.check("failure", "success").returncode, 0) + + def test_synthetic_failure_blocks_aggregate(self): + self.assertNotEqual(self.check("success", "failure").returncode, 0) + + def test_cancelled_dependency_blocks_aggregate(self): + for results in [("cancelled", "success"), ("success", "cancelled")]: + with self.subTest(results=results): + self.assertNotEqual(self.check(*results).returncode, 0) + + def test_skipped_dependency_blocks_aggregate(self): + for results in [("skipped", "success"), ("success", "skipped")]: + with self.subTest(results=results): + self.assertNotEqual(self.check(*results).returncode, 0) + + def test_empty_unknown_and_nonliteral_results_block_aggregate(self): + for result in ["", "unknown", "SUCCESS", "success failure", "success\nsuccess"]: + with self.subTest(result=result): + self.assertNotEqual(self.check(result, "success").returncode, 0) + self.assertNotEqual(self.check("success", result).returncode, 0) + + def test_missing_required_result_blocks_aggregate(self): + self.assertNotEqual(self.check().returncode, 0) + self.assertNotEqual(self.check("success").returncode, 0) + + def test_extra_result_blocks_aggregate(self): + self.assertNotEqual(self.check("success", "success", "success").returncode, 0) + + +if __name__ == "__main__": + unittest.main()