From 2ae034a7f50debce8897e28676fd62c0f440340a Mon Sep 17 00:00:00 2001 From: Jae Hoon Seo Date: Thu, 22 Oct 2026 17:42:00 +0900 Subject: [PATCH 1/3] build(ci): build and smoke-test the release wheels .github/workflows/wheels.yml builds the published distribution as workflow artifacts (manual runs, published releases, and pull requests that touch the packaging); publishing stays a separate step: - sdist, checked with twine; - Linux x86-64 (cibuildwheel, manylinux_2_28, image GCC toolset): OpenBLAS and the Fortran runtime bundled by auditwheel, built for x86-64-v3 (CABLEDYN_TARGET_AVX2), as the Windows release; - macOS arm64 (cibuildwheel, Homebrew gfortran and OpenBLAS, bundled by delocate, deployment target 14.0); - Windows x86-64 (release/build_windows_wheel.ps1): the static release configuration with IFX and MSVC and the release's pinned reference LAPACK; dumpbin checks that the library and the driver import only Windows system DLLs. release/smoke_test_wheel.py installs nothing itself: run in a clean environment with the wheel installed, it requires the package to load the library it carries and select the driver it carries, the driver to report the expected build (--expect-build), and a standalone deck to run. tests/test_packaging_metadata.py keeps the reference LAPACK version, checksum and flags of the wheel and release scripts equal. Signed-off-by: Jae Hoon Seo --- .github/workflows/wheels.yml | 191 +++++++++++++++++++++++++++++++ CHANGELOG.md | 4 + DEVELOPMENT.md | 20 ++++ release/build_windows_wheel.ps1 | 151 ++++++++++++++++++++++++ release/smoke_test_wheel.py | 73 ++++++++++++ tests/test_packaging_metadata.py | 28 +++++ 6 files changed, 467 insertions(+) create mode 100644 .github/workflows/wheels.yml create mode 100644 release/build_windows_wheel.ps1 create mode 100644 release/smoke_test_wheel.py diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml new file mode 100644 index 0000000..02f125f --- /dev/null +++ b/.github/workflows/wheels.yml @@ -0,0 +1,191 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 Jae Hoon Seo, SMI Lab, Inha University +# +# The published Python distribution: an sdist and one py3-none platform wheel each for Linux +# x86-64 (manylinux_2_28), macOS arm64 and Windows x86-64, every wheel carrying the CableDyn +# library and the standalone driver. The x86-64 wheels target x86-64-v3 (AVX2), as the static +# Windows release does. Each wheel is installed into a clean environment and +# smoke-tested (release/smoke_test_wheel.py) on its own platform. The distributions are workflow +# artifacts; publishing them is a separate, explicit step. +name: wheels + +on: + workflow_dispatch: + release: + types: [published] + pull_request: + paths: + - pyproject.toml + - CMakeLists.txt + - python/cabledyn/** + - release/build_windows_wheel.ps1 + - release/smoke_test_wheel.py + - .github/workflows/wheels.yml + +permissions: + contents: read + +jobs: + sdist: + runs-on: ubuntu-latest + steps: + - name: Check out CableDyn + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: '3.13' + + - name: Build and check the sdist + run: | + python -m pip install --upgrade pip build twine + export SOURCE_DATE_EPOCH="$(git log -1 --format=%ct)" + python -m build --sdist --outdir dist . + python -m twine check dist/* + + - name: Upload the sdist + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: cabledyn-sdist + path: dist/*.tar.gz + if-no-files-found: error + + linux: + runs-on: ubuntu-latest + steps: + - name: Check out CableDyn + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Build, repair (auditwheel) and test the manylinux wheel + uses: pypa/cibuildwheel@8c2dcd2e96041a619f5ff03c9638d7941e2553a4 # v4.3.3 + env: + # one py3-none wheel serves every Python; build it once + CIBW_BUILD: cp312-manylinux_x86_64 + CIBW_ARCHS_LINUX: x86_64 + CIBW_MANYLINUX_X86_64_IMAGE: manylinux_2_28 + # gfortran comes with the image's GCC toolset; OpenBLAS supplies LAPACK and is bundled + # into the wheel by auditwheel together with the Fortran runtime + CIBW_BEFORE_ALL_LINUX: >- + dnf install -y epel-release && dnf install -y openblas-devel + # x86-64-v3, as the Windows release (CABLEDYN_TARGET_AVX2): the wheel's library and + # driver stop with a message on a processor without AVX2 + CIBW_ENVIRONMENT_LINUX: SKBUILD_CMAKE_DEFINE="CABLEDYN_TARGET_AVX2=ON" + CIBW_TEST_COMMAND: >- + python {project}/release/smoke_test_wheel.py {project}/examples/dynamic_chain_current.dat --expect-build x86-64-v3 + with: + output-dir: wheelhouse + + - name: Upload the Linux wheel + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: cabledyn-wheel-linux-x86_64 + path: wheelhouse/*.whl + if-no-files-found: error + + macos: + runs-on: macos-14 + steps: + - name: Check out CableDyn + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Build, repair (delocate) and test the macOS wheel + uses: pypa/cibuildwheel@8c2dcd2e96041a619f5ff03c9638d7941e2553a4 # v4.3.3 + env: + CIBW_BUILD: cp312-macosx_arm64 + CIBW_ARCHS_MACOS: arm64 + # Homebrew gfortran and OpenBLAS; delocate bundles them with the Fortran runtime. The + # deployment target is the runner's macOS, the oldest the Homebrew libraries support. + CIBW_BEFORE_ALL_MACOS: brew install gcc openblas + CIBW_ENVIRONMENT_MACOS: >- + MACOSX_DEPLOYMENT_TARGET=14.0 + FC=gfortran + CMAKE_PREFIX_PATH=$(brew --prefix openblas) + CIBW_TEST_COMMAND: >- + python {project}/release/smoke_test_wheel.py {project}/examples/dynamic_chain_current.dat --expect-build baseline + with: + output-dir: wheelhouse + + - name: Upload the macOS wheel + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: cabledyn-wheel-macos-arm64 + path: wheelhouse/*.whl + if-no-files-found: error + + windows: + timeout-minutes: 180 + runs-on: windows-2022 + steps: + - name: Check out CableDyn + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: '3.13' + + - name: Install Intel Fortran Essentials + # the release compiler, as in release-windows-static.yml + shell: powershell + env: + URL: https://registrationcenter-download.intel.com/akdlm/IRC_NAS/33676fcf-14a3-4e96-a9b9-72976b1145d9/intel-fortran-essentials-2025.3.1.25_offline.exe + run: | + $installer = Join-Path $env:RUNNER_TEMP 'intel-fortran-essentials-2025.3.1.25.exe' + curl.exe --fail --location --output $installer --url $env:URL --retry 5 --retry-delay 5 + if ($LASTEXITCODE -ne 0) { throw "Intel Fortran download failed with exit $LASTEXITCODE" } + if ((Get-Item -LiteralPath $installer).Length -ne 880951016) { + throw 'Intel Fortran installer length differs from the pinned release payload' + } + $signature = Get-AuthenticodeSignature -LiteralPath $installer + if ($signature.Status -ne 'Valid' -or + $signature.SignerCertificate.Subject -notmatch '(^|, )O=Intel Corporation(,|$)') { + throw "Intel Fortran installer signature is not a valid Intel Corporation signature: $($signature.Status)" + } + $arguments = @( + '-s', '-a', '--silent', '--eula', 'accept', + '-p=NEED_VS2017_INTEGRATION=0', + '-p=NEED_VS2019_INTEGRATION=0', + '-p=NEED_VS2022_INTEGRATION=1', + '--log-dir=.' + ) + $process = Start-Process -FilePath $installer -ArgumentList $arguments -Wait -PassThru + if ($process.ExitCode -notin @(0, 3010)) { + throw "Intel Fortran installer failed with exit $($process.ExitCode)" + } + + - name: Build the static Windows wheel + shell: powershell + run: .\release\build_windows_wheel.ps1 -OutputDir "$PWD\wheelhouse" -Jobs 4 + + - name: Install the wheel into a clean environment and smoke-test it + shell: powershell + run: | + $venv = Join-Path $env:RUNNER_TEMP 'wheel-test' + python -m venv $venv + & "$venv\Scripts\python.exe" -m pip install --upgrade pip + $wheel = (Get-ChildItem wheelhouse\cabledyn-*-win_amd64.whl).FullName + & "$venv\Scripts\python.exe" -m pip install $wheel + Push-Location $env:RUNNER_TEMP + try { + & "$venv\Scripts\python.exe" "$env:GITHUB_WORKSPACE\release\smoke_test_wheel.py" ` + "$env:GITHUB_WORKSPACE\examples\dynamic_chain_current.dat" --expect-build x86-64-v3 + if ($LASTEXITCODE -ne 0) { throw "smoke test failed ($LASTEXITCODE)" } + } finally { + Pop-Location + } + + - name: Upload the Windows wheel + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: cabledyn-wheel-windows-x86_64 + path: wheelhouse/*.whl + if-no-files-found: error diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e38899..1a26e3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,10 @@ All notable changes to CableDyn are recorded in this file. The format follows inside the `cabledyn` package, so the package needs no separate library; the driver search takes the bundled driver after `CABLEDYN_DRIVER` and before `PATH`. `python/pyproject.toml` remains the pure package for development against a build tree. +- Release wheels built in CI (`.github/workflows/wheels.yml`): Linux x86-64 (`manylinux_2_28`, + x86-64-v3), macOS arm64 and Windows x86-64 (IFX, static, the release's reference LAPACK), plus + the sdist. Each wheel is installed into a clean environment and smoke-tested + (`release/smoke_test_wheel.py`); publishing is a separate step. ### Changed diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 287bb9a..8a908be 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -136,6 +136,26 @@ python -m pip wheel . --no-deps --no-build-isolation -w dist ` A wheel built this way still needs the toolchain's runtime and LAPACK libraries on the search path; the release wheels bundle them (or link them statically on Windows). +The release wheels come from `.github/workflows/wheels.yml` (manual runs, published +releases, and pull requests that touch the packaging), as workflow artifacts; publishing +them is a separate step. It builds the sdist (checked with `twine`) and three wheels: + +| Wheel | Toolchain | Runtime and LAPACK | Build | +|---|---|---|---| +| Linux x86-64 (`manylinux_2_28`) | cibuildwheel, image GCC toolset | OpenBLAS and the Fortran runtime bundled by auditwheel | x86-64-v3 | +| macOS arm64 (14.0+) | cibuildwheel, Homebrew gfortran | OpenBLAS and the Fortran runtime bundled by delocate | baseline | +| Windows x86-64 | `release/build_windows_wheel.ps1`: IFX and MSVC, static runtime | the release's reference LAPACK, linked statically | x86-64-v3 | + +The Windows wheel uses the static release configuration and the same pinned reference +LAPACK as `release/build_static_windows.ps1` (`tests/test_packaging_metadata.py` keeps +the version, checksum and flags equal), and the script checks with `dumpbin` that the +library and the driver import only Windows system DLLs. Each wheel is installed into a +clean environment and checked by `release/smoke_test_wheel.py`: the package loads the +library it carries, selects the driver it carries, the driver reports the expected build +(`--expect-build`), and a standalone deck runs. On a processor without AVX2 the x86-64 +wheels' driver stops with the start-up message, and so does a Python process when it +loads the library. + On Windows, run Python from the activated `cabledyn` environment and point `CABLEDYN_LIBRARY` at the DLL. Do not prepend compiler or OpenBLAS runtime directories to `PATH` before importing NumPy: mixed runtime DLLs can terminate diff --git a/release/build_windows_wheel.ps1 b/release/build_windows_wheel.ps1 new file mode 100644 index 0000000..ee14d2a --- /dev/null +++ b/release/build_windows_wheel.ps1 @@ -0,0 +1,151 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 Jae Hoon Seo, SMI Lab, Inha University +# Build the Windows x64 cabledyn wheel with the release toolchain: Intel IFX and the MSVC linker +# with the static runtime, and the pinned netlib reference LAPACK built here with IFX (the same +# pins as build_static_windows.ps1, kept equal by tests/test_packaging_metadata.py). The library +# and the driver in the wheel therefore need no runtime DLLs beyond Windows itself, which the +# script checks with dumpbin before it writes the wheel to OutputDir. +[CmdletBinding()] +param( + [string]$CableDynRoot = '', + [string]$OutputDir = '', + [string]$Python = 'python', + [string]$WindowsSdkVersion = '10.0.22621.0', + [int]$Jobs = 8, + [version]$MinimumIfxVersion = '2025.3', + [string]$OneApiSetvars = 'C:\Program Files (x86)\Intel\oneAPI\setvars.bat', + [string]$VcVarsAll = '', + # Local copy of the pinned reference LAPACK source archive; downloaded when empty. + [string]$ReferenceLapackArchive = '' +) +$ErrorActionPreference = 'Stop' +if (-not $CableDynRoot) { $CableDynRoot = Split-Path $PSScriptRoot -Parent } +$CableDynRoot = (Resolve-Path -LiteralPath $CableDynRoot).Path +if (-not $OutputDir) { $OutputDir = Join-Path $CableDynRoot 'build-wheel\dist' } +$OutputDir = [IO.Path]::GetFullPath($OutputDir) +$Python = (Get-Command $Python).Source +$vswhere = Join-Path ${env:ProgramFiles(x86)} 'Microsoft Visual Studio\Installer\vswhere.exe' +if (-not (Test-Path -LiteralPath $vswhere)) { throw "Visual Studio locator not found: $vswhere" } +$vsRoot = (& $vswhere -latest -products * -requires 'Microsoft.VisualStudio.Component.VC.Tools.x86.x64' ` + -property installationPath | Select-Object -First 1) +if (-not $vsRoot) { throw 'Visual Studio with the x64 C++ toolchain was not found' } +if (-not $VcVarsAll) { $VcVarsAll = Join-Path $vsRoot 'VC\Auxiliary\Build\vcvarsall.bat' } +foreach ($required in @($OneApiSetvars, $VcVarsAll)) { + if (-not (Test-Path -LiteralPath $required)) { throw "required toolchain script not found: $required" } +} + +function Invoke-Toolchain([string]$Command) { + # Run one command in a cmd.exe with the IFX and MSVC environments loaded. + $bat = Join-Path $env:TEMP ("cabledyn-wheel-{0}.bat" -f [guid]::NewGuid().ToString('N')) + try { + @( + '@echo off' + "call `"$OneApiSetvars`" --force >nul" + 'if errorlevel 1 exit /b %errorlevel%' + "call `"$VcVarsAll`" x64 $WindowsSdkVersion >nul" + 'if errorlevel 1 exit /b %errorlevel%' + $Command + ) | Set-Content -LiteralPath $bat -Encoding ascii + & cmd.exe /d /c $bat + if ($LASTEXITCODE -ne 0) { throw "toolchain command failed ($LASTEXITCODE): $Command" } + } finally { + Remove-Item -LiteralPath $bat -Force -ErrorAction SilentlyContinue + } +} + +# The release compiler gate: the IFX generation of the official executables. +$probe = Join-Path $env:TEMP ("cabledyn-ifx-{0}.txt" -f [guid]::NewGuid().ToString('N')) +try { + Invoke-Toolchain "ifx /QV > `"$probe`" 2>&1" + $ifxText = Get-Content -LiteralPath $probe -Raw +} finally { + Remove-Item -LiteralPath $probe -Force -ErrorAction SilentlyContinue +} +if ($ifxText -notmatch 'Version\s+(\d+\.\d+(?:\.\d+)?)') { throw "could not determine the IFX version from:`n$ifxText" } +if ([version]$Matches[1] -lt $MinimumIfxVersion) { + throw "IFX $($Matches[1]) is below the release minimum $MinimumIfxVersion" +} +Write-Host "PASS compiler gate: IFX $($Matches[1])" + +$common = '-G Ninja -DCMAKE_BUILD_TYPE=Release -DCMAKE_Fortran_COMPILER=ifx ' + + '-DCMAKE_C_COMPILER=cl -DCMAKE_LINKER=link -DCMAKE_MSVC_RUNTIME_LIBRARY=MultiThreaded' + +# The netlib reference LAPACK/BLAS, static, built with IFX (pins shared with +# build_static_windows.ps1). +$lapackVersion = '3.12.1' +$lapackSha256 = '2ca6407a001a474d4d4d35f3a61550156050c48016d949f0da0529c0aa052422' +$lapackUrl = "https://github.com/Reference-LAPACK/lapack/archive/refs/tags/v$lapackVersion.tar.gz" +$lapackRoot = Join-Path $CableDynRoot 'build-wheel\reference-lapack' +New-Item -ItemType Directory -Path $lapackRoot -Force | Out-Null +if (-not $ReferenceLapackArchive) { + $ReferenceLapackArchive = Join-Path $lapackRoot "lapack-$lapackVersion.tar.gz" + if (-not (Test-Path -LiteralPath $ReferenceLapackArchive)) { + Invoke-WebRequest -UseBasicParsing -Uri $lapackUrl -OutFile $ReferenceLapackArchive + } +} +$archiveHash = (Get-FileHash -LiteralPath $ReferenceLapackArchive -Algorithm SHA256).Hash.ToLowerInvariant() +if ($archiveHash -ne $lapackSha256) { + throw "reference LAPACK archive $ReferenceLapackArchive has SHA256 $archiveHash; expected $lapackSha256" +} +$lapackSource = Join-Path $lapackRoot "lapack-$lapackVersion" +$lapackBuild = Join-Path $lapackRoot 'build' +$lapackInstall = Join-Path $lapackRoot 'install' +foreach ($stale in @($lapackSource, $lapackBuild, $lapackInstall)) { + if (Test-Path -LiteralPath $stale) { Remove-Item -LiteralPath $stale -Recurse -Force } +} +& "$env:SystemRoot\System32\tar.exe" -xzf $ReferenceLapackArchive -C $lapackRoot +if ($LASTEXITCODE -ne 0) { throw "could not extract $ReferenceLapackArchive" } +Invoke-Toolchain ("cmake -S `"$lapackSource`" -B `"$lapackBuild`" $common " + + '-DBUILD_SHARED_LIBS=OFF -DBUILD_TESTING=OFF -DBUILD_SINGLE=OFF -DBUILD_DOUBLE=ON ' + + '-DBUILD_COMPLEX=OFF -DBUILD_COMPLEX16=OFF -DBUILD_INDEX64_EXT_API=OFF ' + + '-DUSE_OPTIMIZED_BLAS=OFF -DUSE_OPTIMIZED_LAPACK=OFF ' + + '"-DCMAKE_Fortran_FLAGS=/nologo /libs:static /fp:precise /arch:CORE-AVX2" ' + + "`"-DCMAKE_INSTALL_PREFIX=$lapackInstall`"") +Invoke-Toolchain "cmake --build `"$lapackBuild`" --target blas lapack -j $Jobs" +Invoke-Toolchain "cmake --install `"$lapackBuild`"" +$lapackLibraries = @('liblapack.lib', 'libblas.lib') | ForEach-Object { + $library = Join-Path $lapackInstall "lib\$_" + if (-not (Test-Path -LiteralPath $library)) { throw "reference LAPACK build did not produce $library" } + $library -replace '\\', '/' +} +Write-Host "PASS reference LAPACK $lapackVersion built with IFX" + +# The wheel: scikit-build-core with the release configuration of CableDyn. +New-Item -ItemType Directory -Path $OutputDir -Force | Out-Null +$settings = @( + 'cmake.args=-GNinja', + 'cmake.define.CMAKE_Fortran_COMPILER=ifx', + 'cmake.define.CMAKE_C_COMPILER=cl', + 'cmake.define.CMAKE_LINKER=link', + 'cmake.define.CMAKE_MSVC_RUNTIME_LIBRARY=MultiThreaded', + 'cmake.define.CABLEDYN_STATIC_WINDOWS_RELEASE=ON', + "cmake.define.CABLEDYN_STATIC_LAPACK_LIBRARIES=$($lapackLibraries -join ';')" +) | ForEach-Object { "`"--config-settings=$_`"" } +Invoke-Toolchain ("`"$Python`" -m pip wheel `"$CableDynRoot`" --no-deps -w `"$OutputDir`" " + ($settings -join ' ')) +$wheel = Get-ChildItem -LiteralPath $OutputDir -Filter 'cabledyn-*-win_amd64.whl' | + Sort-Object LastWriteTime | Select-Object -Last 1 +if (-not $wheel) { throw "no win_amd64 wheel in $OutputDir" } + +# Only Windows system DLLs may be imported by the library and the driver. +$inspect = Join-Path $env:TEMP ("cabledyn-wheel-{0}" -f [guid]::NewGuid().ToString('N')) +try { + Expand-Archive -LiteralPath $wheel.FullName -DestinationPath $inspect + $binaries = Get-ChildItem -LiteralPath (Join-Path $inspect 'cabledyn') -Recurse -Include '*.dll', '*.exe' + if ($binaries.Count -lt 2) { throw "the wheel carries $($binaries.Count) binaries; expected the library and the driver" } + foreach ($binary in $binaries) { + $listing = Join-Path $env:TEMP ("cabledyn-deps-{0}.txt" -f [guid]::NewGuid().ToString('N')) + Invoke-Toolchain "dumpbin /nologo /dependents `"$($binary.FullName)`" > `"$listing`"" + $imports = Get-Content -LiteralPath $listing | Where-Object { $_ -match '^\s+\S+\.dll\s*$' } | + ForEach-Object { $_.Trim() } + Remove-Item -LiteralPath $listing -Force + $foreign = $imports | Where-Object { + $_ -notmatch '^(KERNEL32|ADVAPI32|USER32|SHELL32|OLEAUT32|ole32|WS2_32|ntdll|bcrypt|imagehlp|dbghelp|PSAPI|VERSION)\.dll$' -and + $_ -notmatch '^api-ms-win-' + } + if ($foreign) { throw "$($binary.Name) imports non-system DLLs: $($foreign -join ', ')" } + Write-Host "PASS $($binary.Name) imports only system DLLs: $($imports -join ', ')" + } +} finally { + if (Test-Path -LiteralPath $inspect) { Remove-Item -LiteralPath $inspect -Recurse -Force } +} +Write-Host "PASS $($wheel.Name)" diff --git a/release/smoke_test_wheel.py b/release/smoke_test_wheel.py new file mode 100644 index 0000000..f3a2073 --- /dev/null +++ b/release/smoke_test_wheel.py @@ -0,0 +1,73 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 Jae Hoon Seo, SMI Lab, Inha University +"""Smoke-test an installed cabledyn platform wheel, outside the source tree. + +The installed package must load the CableDyn library it carries (not one from a build tree or +CABLEDYN_LIBRARY), select the driver it carries (not one on PATH), and run a standalone deck to +completion. With --expect-build, the driver's --version must report that build ("x86-64-v3" +for the x86-64 release wheels, "baseline" otherwise). Usage: + + python release/smoke_test_wheel.py [--expect-build x86-64-v3|baseline] +""" + +from __future__ import annotations + +import argparse +import os +import subprocess +import sys +import tempfile +from pathlib import Path + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("deck", type=Path) + parser.add_argument("--expect-build", choices=["x86-64-v3", "baseline"]) + args = parser.parse_args() + deck = args.deck.resolve() + for name in ("CABLEDYN_LIBRARY", "CABLEDYN_DRIVER"): + os.environ.pop(name, None) + + import cabledyn + from cabledyn import _discovery, driver + + package = Path(cabledyn.__file__).resolve().parent + library = _discovery.find_library() + if library.parent != package: + print(f"FAIL: the library {library} is not the one in the package {package}", file=sys.stderr) + return 1 + print(f"library : {library}") + print(f"version : {cabledyn.version_string()}") + + executable = driver._resolve_executable(None) + if executable.parent != package / "bin": + print(f"FAIL: the driver {executable} is not the one in {package / 'bin'}", file=sys.stderr) + return 1 + print(f"driver : {executable}") + + if args.expect_build: + banner = subprocess.run([str(executable), "--version"], capture_output=True, text=True, check=False) + build = [line.split(None, 1)[1].strip() for line in banner.stdout.splitlines() if line.split()[:1] == ["Build"]] + if banner.returncode != 0 or not build or not build[0].startswith(args.expect_build): + print( + f"FAIL: the driver reports build {build} (exit {banner.returncode}); expected {args.expect_build}", + file=sys.stderr, + ) + return 1 + print(f"build : {build[0]}") + + with tempfile.TemporaryDirectory() as work: + root = Path(work) / deck.stem + result = driver.CableDynDriver().run(deck, output_root=root) + outputs = sorted(p.name for p in Path(work).iterdir()) + if result is None or not any(name.endswith(".out") for name in outputs): + print(f"FAIL: the driver run wrote no .out file ({outputs})", file=sys.stderr) + return 1 + print(f"deck : {deck.name} ran; outputs {outputs}") + print("PASS: the installed wheel carries a working library and driver") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_packaging_metadata.py b/tests/test_packaging_metadata.py index 049ec46..44cbf9f 100644 --- a/tests/test_packaging_metadata.py +++ b/tests/test_packaging_metadata.py @@ -6,6 +6,7 @@ from __future__ import annotations +import re import unittest from pathlib import Path @@ -61,5 +62,32 @@ def test_the_wheel_classifiers_differ_only_in_the_platforms(self): ) +def _lapack_pins(script: Path) -> dict: + """The reference LAPACK version, archive checksum and Fortran flags a build script pins.""" + text = script.read_text(encoding="utf-8") + pins = {} + for key, pattern in ( + ("version", r"\$lapackVersion = '([^']+)'"), + ("sha256", r"\$lapackSha256 = '([0-9a-f]{64})'"), + ("flags", r'"-DCMAKE_Fortran_FLAGS=([^"]+)"'), + ): + found = re.findall(pattern, text) + if len(found) != 1: + raise AssertionError(f"{script.name}: expected one {key} pin, found {found}") + pins[key] = found[0] + return pins + + +class WindowsWheelLapackTest(unittest.TestCase): + """The Windows wheel links the same reference LAPACK as the static release executables: + one version, one archive checksum, one set of Fortran flags (x86-64-v3 included).""" + + def test_the_wheel_and_the_release_pin_the_same_lapack(self): + release = _lapack_pins(ROOT / "release" / "build_static_windows.ps1") + wheel = _lapack_pins(ROOT / "release" / "build_windows_wheel.ps1") + self.assertEqual(wheel, release) + self.assertIn("/arch:CORE-AVX2", release["flags"]) + + if __name__ == "__main__": unittest.main() From 5cfc1a49af797f3b33c74bbcaa62d33f12acb4af Mon Sep 17 00:00:00 2001 From: Jae Hoon Seo Date: Thu, 22 Oct 2026 18:04:45 +0900 Subject: [PATCH 2/3] fix(ci): inspect the Windows wheel under Windows PowerShell 5.1 The wheels workflow runs release/build_windows_wheel.ps1 with Windows PowerShell 5.1, whose Expand-Archive refuses the .whl extension (NotSupportedArchiveFileExtension), so every Windows wheel build would have stopped before the dependency audit. The wheel is now extracted with System.IO.Compression.ZipFile, which reads it by content. In the same version, Get-ChildItem -LiteralPath ignores -Include and listed every file of the package as a binary, which made the count check meaningless and ran dumpbin on Python modules. The binaries are now filtered on their extension, and the wheel must carry exactly the library and the driver in cabledyn/bin. Signed-off-by: Jae Hoon Seo --- release/build_windows_wheel.ps1 | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/release/build_windows_wheel.ps1 b/release/build_windows_wheel.ps1 index ee14d2a..47731b4 100644 --- a/release/build_windows_wheel.ps1 +++ b/release/build_windows_wheel.ps1 @@ -129,9 +129,20 @@ if (-not $wheel) { throw "no win_amd64 wheel in $OutputDir" } # Only Windows system DLLs may be imported by the library and the driver. $inspect = Join-Path $env:TEMP ("cabledyn-wheel-{0}" -f [guid]::NewGuid().ToString('N')) try { - Expand-Archive -LiteralPath $wheel.FullName -DestinationPath $inspect - $binaries = Get-ChildItem -LiteralPath (Join-Path $inspect 'cabledyn') -Recurse -Include '*.dll', '*.exe' - if ($binaries.Count -lt 2) { throw "the wheel carries $($binaries.Count) binaries; expected the library and the driver" } + # A wheel is a ZIP archive, but Windows PowerShell 5.1's Expand-Archive refuses any + # extension other than .zip; the .NET extractor reads it by content. + Add-Type -AssemblyName System.IO.Compression.FileSystem + [IO.Compression.ZipFile]::ExtractToDirectory($wheel.FullName, $inspect) + # Filter on the extension explicitly: with -LiteralPath, Windows PowerShell 5.1 ignores + # -Include and would list every file of the package. + $binaries = @(Get-ChildItem -LiteralPath (Join-Path $inspect 'cabledyn') -Recurse -File | + Where-Object { $_.Extension -in @('.dll', '.exe') }) + $libraries = @($binaries | Where-Object { $_.Extension -eq '.dll' }) + $drivers = @($binaries | Where-Object { $_.Extension -eq '.exe' -and $_.Directory.Name -eq 'bin' }) + if ($libraries.Count -ne 1 -or $drivers.Count -ne 1 -or $binaries.Count -ne 2) { + throw "the wheel carries $($binaries.Count) binaries ($($binaries.Name -join ', ')); " + + 'expected the library beside the package and the driver in cabledyn/bin' + } foreach ($binary in $binaries) { $listing = Join-Path $env:TEMP ("cabledyn-deps-{0}.txt" -f [guid]::NewGuid().ToString('N')) Invoke-Toolchain "dumpbin /nologo /dependents `"$($binary.FullName)`" > `"$listing`"" From a7e38696215136ad4600352be9713efe4f491dd7 Mon Sep 17 00:00:00 2001 From: Jae Hoon Seo Date: Thu, 22 Oct 2026 18:20:52 +0900 Subject: [PATCH 3/3] fix(build): link the static release's shared library with /Qipo The static Windows release compiles the core with /Qipo, so its objects are IFX intermediate code that only an IPO link can read. The executables get /Qipo through CMAKE_EXE_LINKER_FLAGS; the shared library, which only the Windows wheel builds in this configuration, did not, and IFX handed the objects to link.exe (LNK1107 in the first wheels run). CMAKE_SHARED_LINKER_FLAGS now carries /Qipo too. The resulting cabledyn.dll imports only SHELL32, KERNEL32 and imagehlp, loads through the Python package, and stops at load with the x86-64-v3 message when the processor check fails. Signed-off-by: Jae Hoon Seo --- CMakeLists.txt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 27f4816..28b84a8 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -46,8 +46,11 @@ if(CABLEDYN_STATIC_WINDOWS_RELEASE) # driver below, or the OpenFAST Release|x64 project, which enables whole-program # optimisation). string(APPEND CMAKE_Fortran_FLAGS " /libs:static /heap-arrays:1024 /Qipo") - # CMake does not pass the compile flags to an IFX executable link on Windows. + # CMake does not pass the compile flags to an IFX executable link on Windows. The shared + # library (the Windows wheel's cabledyn.dll) links the same IPO objects, so it needs the + # flag as well: without it IFX hands the intermediate objects to link.exe (LNK1107). string(APPEND CMAKE_EXE_LINKER_FLAGS " /Qipo") + string(APPEND CMAKE_SHARED_LINKER_FLAGS " /Qipo") # LAPACK/BLAS: the netlib reference implementation compiled with this IFX # (release/build_static_windows.ps1 builds it), not MKL. Every CableDyn system is # banded with a small bandwidth, and MKL's sequential DGBTRF/DGBTRS/DTBSV spend