diff --git a/.github/scripts/test_ci_workflow_contracts.py b/.github/scripts/test_ci_workflow_contracts.py index 274b3001..86813822 100644 --- a/.github/scripts/test_ci_workflow_contracts.py +++ b/.github/scripts/test_ci_workflow_contracts.py @@ -17,6 +17,81 @@ class WorkflowContractsTest(unittest.TestCase): + def test_wheels_preserve_abi_coverage_and_release_gates(self): + workflow = (ROOT / ".github/workflows/release.yml").read_text() + config = tomllib.loads((ROOT / "pyproject.toml").read_text()) + wheels = config["tool"]["cibuildwheel"] + # Keep the full installed-wheel suite and platform repair hooks. + self.assertIn("pytest {project}/tests/python", wheels["test-command"]) + self.assertIn("delocate-wheel", wheels["macos"]["repair-wheel-command"]) + self.assertIn("delvewheel repair", wheels["windows"]["repair-wheel-command"]) + self.assertIn("scripts/build-macos-openmp.sh", wheels["macos"]["before-all"]) + self.assertEqual(workflow.count("pattern: '*-wheels'"), 2) + artifacts = set() + for prefix, tag, check_name in ( + ("linux", "manylinux_x86_64", "Linux x86-64 wheels"), + ( + "linux-arm64", + "manylinux_aarch64", + "Linux ARM64 wheels (CPython 3.11–3.14)", + ), + ("windows", "win_amd64", "Windows x86-64 wheels"), + ("macos", "macosx_arm64", "macOS arm64 wheels (CPython 3.11–3.14)"), + ): + with self.subTest(platform=prefix): + build = workflow.split(f" {prefix}-wheel-build:\n", 1)[1].split( + f" {prefix}-wheels:\n", 1 + )[0] + selection = re.search( + r"CIBW_BUILD: \$\{\{ github.event_name == 'pull_request' " + r"&& '([^']+)' \|\| '([^']+)' \}\}", + build, + ) + self.assertIsNotNone(selection) + supported = { + identifier + for identifier in wheels["build"].split() + if identifier.endswith(f"-{tag}") + } + for patterns, abis in zip( + selection.groups(), ((311, 314), (311, 312, 313, 314)), strict=True + ): + selected = { + identifier + for identifier in supported + if any( + fnmatch.fnmatchcase(identifier, p) for p in patterns.split() + ) + } + self.assertEqual(selected, {f"cp{abi}-{tag}" for abi in abis}) + self.assertNotIn("CIBW_TEST", build) + self.assertNotIn("continue-on-error", build) + self.assertNotIn("matrix", build) + artifact = re.search(r" name: (\S+)", build).group(1) + self.assertTrue(fnmatch.fnmatchcase(artifact, "*-wheels")) + self.assertNotIn(artifact, artifacts) + artifacts.add(artifact) + gate = re.search( + rf"^ {prefix}-wheels:\n(.*?)(?=^ [\w-]+:|\Z)", + workflow, + re.MULTILINE | re.DOTALL, + ).group(1) + self.assertIn(f"name: {check_name}", gate) + self.assertIn(f"needs: {prefix}-wheel-build", gate) + self.assertIn( + f"BUILD_RESULT: ${{{{ needs.{prefix}-wheel-build.result }}}}", gate + ) + command = gate.split(" run: ", 1)[1].strip() + for result in ("success", "failure", "cancelled", "skipped", ""): + with self.subTest(result=result): + verdict = subprocess.run( + ["bash", "-e", "-c", command], + env=dict(os.environ, BUILD_RESULT=result), + capture_output=True, + check=False, + ) + self.assertEqual(verdict.returncode == 0, result == "success") + def test_installed_typing_checks_are_required(self): config = tomllib.loads((ROOT / "pyproject.toml").read_text()) test_dependencies = config["project"]["optional-dependencies"]["test"] @@ -54,7 +129,7 @@ def test_installed_typing_checks_are_required(self): )[0] self.assertIn('pip install ".[test,typing]"', build) self.assertIn( - "python -m pytest tests/python/test_editor_support.py tests/test_mypy.py", + "python -m pytest tests/python/test_editor_support.py::test_installed_public_declarations", build, ) self.assertNotIn("continue-on-error", build) @@ -69,12 +144,49 @@ def test_installed_typing_checks_are_required(self): for path in ( "python_bindings/__init__.pyi", "tests/python/typing/public_api.py", - "tests/test_mypy.py", ): with self.subTest(path=path): self.assertLessEqual( {"python_build", "python_quality", "wheels"}, classify([path]) ) + self.assertEqual( + {"python_quality", "python_build", "wheels"}, + classify(["tests/test_mypy.py"]), + ) + + def test_nonrelease_push_keeps_typing_checks_when_wheels_skip(self): + from test_prepare_release import ReleaseTest + + release = ReleaseTest().run_prepare(tags="v0.3.3", tag_sha="b" * 40) + self.assertEqual(release["build"], "false") + workflow = (ROOT / ".github/workflows/python.yml").read_text() + build = workflow.split(" python-build:\n", 1)[1].split( + " python-result:\n", 1 + )[0] + steps = re.findall( + r"^ - name: (.*?)(?=^ - |\Z)", + build, + re.MULTILINE | re.DOTALL, + ) + typing_steps = [step for step in steps if "tests/test_mypy.py" in step] + self.assertEqual(len(typing_steps), 1) + self.assertIn("if: github.event_name == 'push'", typing_steps[0]) + self.assertIn( + "run: python -m pytest tests/python/test_editor_support.py tests/test_mypy.py", + typing_steps[0], + ) + declaration_steps = [ + step for step in steps if "::test_installed_public_declarations" in step + ] + self.assertEqual(len(declaration_steps), 1) + self.assertIn("if: github.event_name == 'pull_request'", declaration_steps[0]) + for path in ( + "tests/test_mypy.py", + "tests/python/test_editor_support.py", + "python_bindings/__init__.pyi", + ): + with self.subTest(path=path): + self.assertIn("python_build", classify([path])) def test_pyright_checks_are_required_and_scoped(self): config = tomllib.loads((ROOT / "pyproject.toml").read_text()) diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index 361d8f64..3658e8e6 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -73,7 +73,14 @@ jobs: - name: Build and install wheel run: pip install ".[test,typing]" -v - - name: Check installed Python types and editor metadata + # PR wheels own mypy and the full editor suite. Non-release branch pushes + # may skip wheels, so source installs must retain those checks on pushes. + - name: Check source-installed Python declarations + if: github.event_name == 'pull_request' + run: python -m pytest tests/python/test_editor_support.py::test_installed_public_declarations --tb=short -q + + - name: Check installed Python types and editor metadata on pushes + if: github.event_name == 'push' env: OMP_NUM_THREADS: "2" run: python -m pytest tests/python/test_editor_support.py tests/test_mypy.py --tb=short -q diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 625d53ce..56c23e07 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -56,7 +56,7 @@ jobs: run: python .github/scripts/prepare_release.py linux-wheel-build: - name: Linux x86-64 ${{ matrix.abi }} wheel + name: Linux x86-64 wheel builds needs: [prepare, changes] if: >- !cancelled() && needs.prepare.result == 'success' && @@ -64,10 +64,6 @@ jobs: (needs.changes.result != 'success' || needs.changes.outputs.wheels == 'true') runs-on: ubuntu-latest timeout-minutes: 90 - strategy: - fail-fast: false - matrix: - abi: ${{ fromJSON(github.event_name == 'pull_request' && '["cp311","cp314"]' || '["cp311","cp312","cp313","cp314"]') }} steps: - uses: actions/checkout@v7 with: @@ -75,16 +71,16 @@ jobs: - uses: actions/setup-python@v7 with: python-version: "3.14" - - name: Build and test wheel + - name: Build and test wheels uses: pypa/cibuildwheel@v4.2.0 env: - CIBW_BUILD: ${{ matrix.abi }}-manylinux_x86_64 + CIBW_BUILD: ${{ github.event_name == 'pull_request' && 'cp311-manylinux_x86_64 cp314-manylinux_x86_64' || 'cp311-manylinux_x86_64 cp312-manylinux_x86_64 cp313-manylinux_x86_64 cp314-manylinux_x86_64' }} with: output-dir: wheelhouse - name: Store wheel artifact uses: actions/upload-artifact@v7 with: - name: linux-${{ matrix.abi }}-wheels + name: linux-wheels path: wheelhouse/*.whl if-no-files-found: error @@ -101,7 +97,7 @@ jobs: run: test "$BUILD_RESULT" = success linux-arm64-wheel-build: - name: Linux ARM64 ${{ matrix.abi }} wheel + name: Linux ARM64 wheel builds needs: [prepare, changes] if: >- !cancelled() && needs.prepare.result == 'success' && @@ -109,10 +105,6 @@ jobs: (needs.changes.result != 'success' || needs.changes.outputs.wheels == 'true') runs-on: ubuntu-24.04-arm timeout-minutes: 90 - strategy: - fail-fast: false - matrix: - abi: ${{ fromJSON(github.event_name == 'pull_request' && '["cp311","cp314"]' || '["cp311","cp312","cp313","cp314"]') }} steps: - uses: actions/checkout@v7 with: @@ -122,16 +114,16 @@ jobs: python-version: "3.14" - name: Verify native ARM64 runner run: test "$(uname -m)" = aarch64 - - name: Build and test repaired wheel + - name: Build and test repaired wheels uses: pypa/cibuildwheel@v4.2.0 env: - CIBW_BUILD: ${{ matrix.abi }}-manylinux_aarch64 + CIBW_BUILD: ${{ github.event_name == 'pull_request' && 'cp311-manylinux_aarch64 cp314-manylinux_aarch64' || 'cp311-manylinux_aarch64 cp312-manylinux_aarch64 cp313-manylinux_aarch64 cp314-manylinux_aarch64' }} with: output-dir: wheelhouse - name: Store wheel artifact uses: actions/upload-artifact@v7 with: - name: linux-arm64-${{ matrix.abi }}-wheels + name: linux-arm64-wheels path: wheelhouse/*.whl if-no-files-found: error @@ -147,7 +139,7 @@ jobs: run: test "$BUILD_RESULT" = success windows-wheel-build: - name: Windows x86-64 ${{ matrix.abi }} wheel + name: Windows x86-64 wheel builds needs: [prepare, changes] if: >- !cancelled() && needs.prepare.result == 'success' && @@ -155,10 +147,6 @@ jobs: (needs.changes.result != 'success' || needs.changes.outputs.wheels == 'true') runs-on: windows-2025-vs2026 timeout-minutes: 90 - strategy: - fail-fast: false - matrix: - abi: ${{ fromJSON(github.event_name == 'pull_request' && '["cp311","cp314"]' || '["cp311","cp312","cp313","cp314"]') }} steps: - uses: actions/checkout@v7 with: @@ -166,16 +154,16 @@ jobs: - uses: actions/setup-python@v7 with: python-version: "3.14" - - name: Build and test wheel + - name: Build and test wheels uses: pypa/cibuildwheel@v4.2.0 env: - CIBW_BUILD: ${{ matrix.abi }}-win_amd64 + CIBW_BUILD: ${{ github.event_name == 'pull_request' && 'cp311-win_amd64 cp314-win_amd64' || 'cp311-win_amd64 cp312-win_amd64 cp313-win_amd64 cp314-win_amd64' }} with: output-dir: wheelhouse - name: Store wheel artifact uses: actions/upload-artifact@v7 with: - name: windows-${{ matrix.abi }}-wheels + name: windows-wheels path: wheelhouse/*.whl if-no-files-found: error @@ -191,7 +179,7 @@ jobs: run: test "$BUILD_RESULT" = success macos-wheel-build: - name: macOS arm64 ${{ matrix.abi }} wheel + name: macOS arm64 wheel builds needs: [prepare, changes] if: >- !cancelled() && needs.prepare.result == 'success' && @@ -199,10 +187,7 @@ jobs: (needs.changes.result != 'success' || needs.changes.outputs.wheels == 'true') runs-on: macos-15 timeout-minutes: 90 - strategy: - fail-fast: false - matrix: - abi: ${{ fromJSON(github.event_name == 'pull_request' && '["cp311","cp314"]' || '["cp311","cp312","cp313","cp314"]') }} + # Share one runner and one before-all OpenMP build across the selected ABIs. steps: - uses: actions/checkout@v7 with: @@ -212,16 +197,16 @@ jobs: python-version: "3.14" - name: Verify native Apple Silicon runner run: test "$(uname -m)" = arm64 - - name: Build and test wheel with bundled OpenMP + - name: Build and test wheels with bundled OpenMP uses: pypa/cibuildwheel@v4.2.0 env: - CIBW_BUILD: ${{ matrix.abi }}-macosx_arm64 + CIBW_BUILD: ${{ github.event_name == 'pull_request' && 'cp311-macosx_arm64 cp314-macosx_arm64' || 'cp311-macosx_arm64 cp312-macosx_arm64 cp313-macosx_arm64 cp314-macosx_arm64' }} with: output-dir: wheelhouse - name: Store wheel artifact uses: actions/upload-artifact@v7 with: - name: macos-${{ matrix.abi }}-wheels + name: macos-wheels path: wheelhouse/*.whl if-no-files-found: error