.NET Build, Test & Publish #34
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: .NET Build, Test & Publish | |
| on: | |
| workflow_dispatch: | |
| inputs: | |
| libwebp_version: | |
| description: 'libwebp semver for native packages (e.g., 1.6.0)' | |
| required: false | |
| type: string | |
| native_release_tag: | |
| description: 'GitHub release tag to download native binaries from' | |
| required: false | |
| type: string | |
| push: | |
| branches: [master, main, develop] | |
| paths-ignore: | |
| - '**.md' | |
| pull_request: | |
| branches: [master, main, develop] | |
| release: | |
| types: [published] | |
| permissions: | |
| contents: read | |
| env: | |
| DOTNET_NOLOGO: true | |
| DOTNET_CLI_TELEMETRY_OPTOUT: true | |
| jobs: | |
| build-and-test: | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| include: | |
| # Windows x64 — all frameworks | |
| - os: windows-latest | |
| rid: win-x64 | |
| test_frameworks: 'net472;net48;net8.0;net10.0' | |
| arch: x64 | |
| # Windows x86 (32-bit) — .NET Framework + net8.0 | |
| - os: windows-latest | |
| rid: win-x86 | |
| test_frameworks: 'net472;net48;net8.0' | |
| arch: x86 | |
| run_settings: '<RunSettings><RunConfiguration><TargetPlatform>x86</TargetPlatform></RunConfiguration></RunSettings>' | |
| # Windows ARM64 (.NET 4.8.1+ for Framework support) | |
| - os: windows-11-arm | |
| rid: win-arm64 | |
| test_frameworks: 'net48;net8.0;net10.0' | |
| arch: arm64 | |
| # Linux x64 | |
| - os: ubuntu-latest | |
| rid: linux-x64 | |
| test_frameworks: 'net8.0;net10.0' | |
| arch: x64 | |
| # Linux ARM64 | |
| - os: ubuntu-24.04-arm | |
| rid: linux-arm64 | |
| test_frameworks: 'net8.0;net10.0' | |
| arch: arm64 | |
| # macOS ARM64 | |
| - os: macos-latest | |
| rid: osx-arm64 | |
| test_frameworks: 'net8.0;net10.0' | |
| arch: arm64 | |
| # macOS x64 — macos-13 (last x64 runner) is retired; | |
| # osx-x64 is tested via Rosetta on macos-latest (arm64) if needed | |
| # Re-enable when GitHub provides an x64 macOS runner | |
| runs-on: ${{ matrix.os }} | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v4 | |
| - name: Setup .NET | |
| uses: actions/setup-dotnet@v4 | |
| with: | |
| dotnet-version: | | |
| 8.0.x | |
| 10.0.x | |
| - name: Install x86 .NET runtime for testing | |
| if: matrix.arch == 'x86' | |
| shell: pwsh | |
| run: | | |
| # VSTest with <TargetPlatform>x86</TargetPlatform> needs an x86 dotnet host | |
| # to launch the testhost process for .NET Core TFMs. | |
| $installScript = "$env:TEMP\dotnet-install.ps1" | |
| Invoke-WebRequest 'https://dot.net/v1/dotnet-install.ps1' -OutFile $installScript | |
| & $installScript -Channel 8.0 -Architecture x86 -Runtime dotnet ` | |
| -InstallDir "${env:ProgramFiles(x86)}\dotnet" | |
| - name: Determine native release tag | |
| id: native | |
| shell: bash | |
| run: | | |
| if [ -n "${{ inputs.native_release_tag }}" ]; then | |
| echo "tag=${{ inputs.native_release_tag }}" >> "$GITHUB_OUTPUT" | |
| else | |
| # Find the latest native-v* release | |
| TAG=$(curl -sL -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \ | |
| "https://api.github.com/repos/${{ github.repository }}/releases" | \ | |
| jq -r '[.[] | select(.tag_name | startswith("native-v"))][0].tag_name // empty') | |
| if [ -z "$TAG" ]; then | |
| echo "::error::No native release found. Run build-libwebp.yml first." | |
| exit 1 | |
| else | |
| echo "tag=${TAG}" >> "$GITHUB_OUTPUT" | |
| fi | |
| fi | |
| - name: Download native binaries from GitHub release | |
| shell: bash | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: | | |
| TAG="${{ steps.native.outputs.tag }}" | |
| SEMVER="${TAG#native-v}" | |
| # Determine which RIDs to download for this runner | |
| case "${{ runner.os }}-${{ runner.arch }}" in | |
| Windows-X64) RIDS="win-x64 win-x86 win-arm64" ;; | |
| Windows-ARM64) RIDS="win-arm64" ;; | |
| Linux-X64) RIDS="linux-x64" ;; | |
| Linux-ARM64) RIDS="linux-arm64" ;; | |
| macOS-ARM64) RIDS="osx-arm64" ;; | |
| macOS-X64) RIDS="osx-x64" ;; | |
| *) RIDS="" ;; | |
| esac | |
| for RID in $RIDS; do | |
| DEST="src/Imazen.WebP.NativeRuntime.${RID}/runtimes/${RID}/native/" | |
| mkdir -p "$DEST" | |
| gh release download "$TAG" \ | |
| --repo "${{ github.repository }}" \ | |
| --pattern "libwebp-${SEMVER}-${RID}.zip" \ | |
| --dir /tmp/native-dl/ | |
| unzip -o "/tmp/native-dl/libwebp-${SEMVER}-${RID}.zip" -d "$DEST" | |
| ls -la "$DEST" | |
| done | |
| - name: Fix macOS dylib rpaths | |
| if: runner.os == 'macOS' | |
| shell: bash | |
| run: | | |
| # The native build embeds build-directory @rpath references that don't | |
| # exist at runtime. Rewrite dependencies to use @loader_path so each | |
| # dylib finds its dependencies in the same directory. | |
| find src/Imazen.WebP.NativeRuntime.*/runtimes/*/native/ -name "*.dylib" 2>/dev/null | while read dylib; do | |
| dir=$(dirname "$dylib") | |
| otool -L "$dylib" 2>/dev/null | awk '/@rpath\//{print $1}' | while read dep; do | |
| base=$(basename "$dep") | |
| # Strip version: libfoo.N.dylib → libfoo.dylib | |
| unversioned=$(echo "$base" | sed -E 's/\.[0-9]+\.dylib/.dylib/') | |
| if [ -f "$dir/$unversioned" ]; then | |
| install_name_tool -change "$dep" "@loader_path/$unversioned" "$dylib" | |
| elif [ -f "$dir/$base" ]; then | |
| install_name_tool -change "$dep" "@loader_path/$base" "$dylib" | |
| fi | |
| done | |
| done | |
| - name: Build | |
| run: dotnet build --configuration Release | |
| - name: Copy native binaries to test output | |
| shell: bash | |
| run: | | |
| # NativeRuntime packages use NuGet-only conventions (.targets, runtimes/ graph) | |
| # that don't apply via ProjectReference. Copy native files to where | |
| # NativeLibraryLoader will find them: runtimes/{rid}/native/ under test output. | |
| # Also copy the matching RID directly to the output root for .NET Framework | |
| # DllImport resolution (which doesn't search runtimes/ subdirectories). | |
| MATCHING_RID="${{ matrix.rid }}" | |
| for RID_DIR in src/Imazen.WebP.NativeRuntime.*/runtimes/*/native/; do | |
| if [ -d "$RID_DIR" ] && ls "$RID_DIR"/*.* >/dev/null 2>&1; then | |
| TEMP="${RID_DIR%/native/}" | |
| RID="${TEMP##*/}" | |
| for TFM_DIR in src/Imazen.Test.Webp/bin/Release/*/; do | |
| DEST="${TFM_DIR}runtimes/${RID}/native/" | |
| mkdir -p "$DEST" | |
| cp "$RID_DIR"* "$DEST/" | |
| # Copy matching RID to output root for .NET Framework DllImport | |
| if [ "$RID" = "$MATCHING_RID" ]; then | |
| cp "$RID_DIR"* "$TFM_DIR/" | |
| fi | |
| done | |
| echo "Copied ${RID} native files to test output directories" | |
| fi | |
| done | |
| - name: Create .runsettings for x86 | |
| if: matrix.run_settings != '' | |
| shell: bash | |
| run: | | |
| cat > test.runsettings <<'SETTINGS' | |
| <?xml version="1.0" encoding="utf-8"?> | |
| ${{ matrix.run_settings }} | |
| SETTINGS | |
| - name: Test (x86 with runsettings) | |
| if: matrix.run_settings != '' | |
| shell: bash | |
| run: | | |
| IFS=';' read -ra FRAMEWORKS <<< "${{ matrix.test_frameworks }}" | |
| FAIL=0 | |
| for fw in "${FRAMEWORKS[@]}"; do | |
| echo "::group::Testing $fw (x86)" | |
| dotnet test --configuration Release --no-build --verbosity normal \ | |
| -f "$fw" --settings test.runsettings || FAIL=1 | |
| echo "::endgroup::" | |
| done | |
| exit $FAIL | |
| env: | |
| DOTNET_SYSTEM_DRAWING_COMMON_ENABLE_UNIX_SUPPORT: false | |
| DOTNET_ROOT_X86: C:\Program Files (x86)\dotnet | |
| - name: Test | |
| if: matrix.run_settings == '' | |
| shell: bash | |
| run: | | |
| IFS=';' read -ra FRAMEWORKS <<< "${{ matrix.test_frameworks }}" | |
| FAIL=0 | |
| for fw in "${FRAMEWORKS[@]}"; do | |
| echo "::group::Testing $fw" | |
| dotnet test --configuration Release --no-build --verbosity normal -f "$fw" || FAIL=1 | |
| echo "::endgroup::" | |
| done | |
| exit $FAIL | |
| env: | |
| DOTNET_SYSTEM_DRAWING_COMMON_ENABLE_UNIX_SUPPORT: false | |
| # ── Package consumption tests ────────────────────────────────────── | |
| # Verify that NuGet packages work end-to-end: | |
| # .NET Core: runtimes/{rid}/native/ resolved via deps.json | |
| # .NET Framework: .targets copies native files to output directory | |
| - name: Pack NuGet packages for consumption test | |
| if: success() || failure() | |
| shell: bash | |
| run: | | |
| mkdir -p artifacts | |
| RID="${{ matrix.rid }}" | |
| dotnet pack src/Imazen.WebP/Imazen.WebP.csproj -c Release -o artifacts/ --no-build | |
| # NativeRuntime projects aren't part of the solution build, so they need restore | |
| dotnet pack "src/Imazen.WebP.NativeRuntime.${RID}/Imazen.WebP.NativeRuntime.${RID}.csproj" \ | |
| -c Release -o artifacts/ | |
| - name: Test package consumption | |
| if: success() || failure() | |
| shell: bash | |
| run: | | |
| RID="${{ matrix.rid }}" | |
| cd test/PackageConsumption | |
| # Determine frameworks and extra args | |
| EXTRA_ARGS="" | |
| if [ "${{ matrix.arch }}" = "x86" ]; then | |
| # x86: only test .NET Framework (.NET Core x86 requires an x86 host) | |
| FRAMEWORKS="net472;net48" | |
| EXTRA_ARGS="-p:PlatformTarget=x86" | |
| elif [ "${{ matrix.arch }}" = "arm64" ] && [[ "${{ matrix.rid }}" == win-* ]]; then | |
| # Windows ARM64: .NET Framework runs under x64 emulation, so it can't | |
| # load arm64 native DLLs. Only test .NET Core frameworks here. | |
| FRAMEWORKS=$(echo "${{ matrix.test_frameworks }}" | tr ';' '\n' | grep -v '^net4' | tr '\n' ';' | sed 's/;$//') | |
| else | |
| FRAMEWORKS="${{ matrix.test_frameworks }}" | |
| fi | |
| dotnet restore -p:NativeRid=$RID $EXTRA_ARGS | |
| dotnet build -c Release -p:NativeRid=$RID $EXTRA_ARGS --no-restore | |
| IFS=';' read -ra FW_ARRAY <<< "$FRAMEWORKS" | |
| FAIL=0 | |
| for fw in "${FW_ARRAY[@]}"; do | |
| echo "::group::Package consumption: $fw ($RID)" | |
| dotnet run -c Release -f "$fw" -p:NativeRid=$RID $EXTRA_ARGS --no-build || FAIL=1 | |
| echo "::endgroup::" | |
| done | |
| exit $FAIL | |
| # Windows heap corruption detection via PageHeap | |
| heap-validation: | |
| runs-on: windows-latest | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v4 | |
| - name: Setup .NET | |
| uses: actions/setup-dotnet@v4 | |
| with: | |
| dotnet-version: 8.0.x | |
| - name: Determine native release tag | |
| id: native | |
| shell: bash | |
| run: | | |
| if [ -n "${{ inputs.native_release_tag }}" ]; then | |
| echo "tag=${{ inputs.native_release_tag }}" >> "$GITHUB_OUTPUT" | |
| else | |
| TAG=$(curl -sL -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \ | |
| "https://api.github.com/repos/${{ github.repository }}/releases" | \ | |
| jq -r '[.[] | select(.tag_name | startswith("native-v"))][0].tag_name // empty') | |
| if [ -z "$TAG" ]; then | |
| echo "::error::No native release found. Run build-libwebp.yml first." | |
| exit 1 | |
| fi | |
| echo "tag=${TAG}" >> "$GITHUB_OUTPUT" | |
| fi | |
| - name: Download native binaries | |
| shell: bash | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: | | |
| TAG="${{ steps.native.outputs.tag }}" | |
| SEMVER="${TAG#native-v}" | |
| for RID in win-x64 win-x86; do | |
| DEST="src/Imazen.WebP.NativeRuntime.${RID}/runtimes/${RID}/native/" | |
| mkdir -p "$DEST" | |
| gh release download "$TAG" \ | |
| --repo "${{ github.repository }}" \ | |
| --pattern "libwebp-${SEMVER}-${RID}.zip" \ | |
| --dir /tmp/native-dl/ | |
| unzip -o "/tmp/native-dl/libwebp-${SEMVER}-${RID}.zip" -d "$DEST" | |
| done | |
| - name: Build | |
| run: dotnet build --configuration Release | |
| - name: Copy native binaries to test output | |
| shell: bash | |
| run: | | |
| for RID_DIR in src/Imazen.WebP.NativeRuntime.*/runtimes/*/native/; do | |
| if [ -d "$RID_DIR" ] && ls "$RID_DIR"/*.* >/dev/null 2>&1; then | |
| TEMP="${RID_DIR%/native/}" | |
| RID="${TEMP##*/}" | |
| for TFM_DIR in src/Imazen.Test.Webp/bin/Release/*/; do | |
| DEST="${TFM_DIR}runtimes/${RID}/native/" | |
| mkdir -p "$DEST" | |
| cp "$RID_DIR"* "$DEST/" | |
| # Copy win-x64 to output root for .NET Framework DllImport | |
| if [ "$RID" = "win-x64" ]; then | |
| cp "$RID_DIR"* "$TFM_DIR/" | |
| fi | |
| done | |
| echo "Copied ${RID} native files to test output directories" | |
| fi | |
| done | |
| - name: Enable PageHeap for test host | |
| shell: pwsh | |
| run: | | |
| # Enable full page heap for the test host processes | |
| # This catches buffer overruns, use-after-free, double-free in native code | |
| $regBase = "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Image File Execution Options" | |
| foreach ($exe in @("testhost.exe", "testhost.x86.exe")) { | |
| $regPath = "$regBase\$exe" | |
| if (-not (Test-Path $regPath)) { | |
| New-Item -Path $regPath -Force | Out-Null | |
| } | |
| # 0x02000000 = FLG_HEAP_PAGE_ALLOCS | |
| New-ItemProperty -Path $regPath -Name "GlobalFlag" -Value 0x02000000 -PropertyType DWord -Force | Out-Null | |
| # 0x3 = full page heap | |
| New-ItemProperty -Path $regPath -Name "PageHeapFlags" -Value 0x3 -PropertyType DWord -Force | Out-Null | |
| Write-Host "Enabled PageHeap for $exe" | |
| } | |
| - name: Run tests with PageHeap | |
| run: dotnet test --configuration Release --no-build --verbosity normal -f net8.0 | |
| env: | |
| DOTNET_SYSTEM_DRAWING_COMMON_ENABLE_UNIX_SUPPORT: false | |
| pack-and-publish: | |
| needs: build-and-test | |
| runs-on: ubuntu-latest | |
| # Run when: manual release (v* tag), or auto-triggered by build-libwebp (workflow_dispatch with inputs) | |
| if: >- | |
| github.event_name == 'release' || | |
| (github.event_name == 'workflow_dispatch' && inputs.libwebp_version != '') | |
| permissions: | |
| contents: read | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v4 | |
| - name: Setup .NET | |
| uses: actions/setup-dotnet@v4 | |
| with: | |
| dotnet-version: 8.0.x | |
| - name: Determine native release tag | |
| id: native | |
| shell: bash | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: | | |
| # Use input if provided, otherwise find the latest native release | |
| if [ -n "${{ inputs.native_release_tag }}" ]; then | |
| echo "tag=${{ inputs.native_release_tag }}" >> "$GITHUB_OUTPUT" | |
| else | |
| TAG=$(curl -sL -H "Authorization: token $GH_TOKEN" \ | |
| "https://api.github.com/repos/${{ github.repository }}/releases" | \ | |
| jq -r '[.[] | select(.tag_name | startswith("native-v"))][0].tag_name // empty') | |
| echo "tag=${TAG}" >> "$GITHUB_OUTPUT" | |
| fi | |
| - name: Determine versions | |
| id: versions | |
| shell: bash | |
| run: | | |
| # Managed API version from git tag (release event) or Directory.Build.props | |
| if [[ "${{ github.ref }}" == refs/tags/v* ]]; then | |
| API_VERSION="${GITHUB_REF#refs/tags/v}" | |
| else | |
| API_VERSION="" | |
| fi | |
| echo "api_version=${API_VERSION}" >> "$GITHUB_OUTPUT" | |
| # Native package version from input or from the native release tag | |
| NATIVE_VERSION="${{ inputs.libwebp_version }}" | |
| if [ -z "$NATIVE_VERSION" ] && [ -n "${{ steps.native.outputs.tag }}" ]; then | |
| NATIVE_VERSION="${{ steps.native.outputs.tag }}" | |
| NATIVE_VERSION="${NATIVE_VERSION#native-v}" | |
| fi | |
| echo "native_version=${NATIVE_VERSION}" >> "$GITHUB_OUTPUT" | |
| echo "API version: ${API_VERSION:-<from Directory.Build.props>}" | |
| echo "Native package version: ${NATIVE_VERSION:-<from Directory.Build.props>}" | |
| - name: Download all native binaries | |
| if: steps.native.outputs.tag != '' | |
| shell: bash | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: | | |
| TAG="${{ steps.native.outputs.tag }}" | |
| SEMVER="${TAG#native-v}" | |
| for RID in win-x64 win-x86 win-arm64 linux-x64 linux-arm64 osx-x64 osx-arm64; do | |
| DEST="src/Imazen.WebP.NativeRuntime.${RID}/runtimes/${RID}/native/" | |
| mkdir -p "$DEST" | |
| gh release download "$TAG" \ | |
| --repo "${{ github.repository }}" \ | |
| --pattern "libwebp-${SEMVER}-${RID}.zip" \ | |
| --dir /tmp/native-dl/ | |
| unzip -o "/tmp/native-dl/libwebp-${SEMVER}-${RID}.zip" -d "$DEST" | |
| done | |
| - name: Pack managed packages | |
| run: | | |
| API_VERSION="${{ steps.versions.outputs.api_version }}" | |
| EXTRA_ARGS="" | |
| if [ -n "$API_VERSION" ]; then | |
| EXTRA_ARGS="-p:Version=${API_VERSION}" | |
| fi | |
| dotnet pack src/Imazen.WebP/Imazen.WebP.csproj --configuration Release $EXTRA_ARGS -o packages/ | |
| - name: Pack native runtime packages | |
| run: | | |
| NATIVE_VERSION="${{ steps.versions.outputs.native_version }}" | |
| EXTRA_ARGS="" | |
| if [ -n "$NATIVE_VERSION" ]; then | |
| EXTRA_ARGS="-p:Version=${NATIVE_VERSION}" | |
| fi | |
| for RID in win-x64 win-x86 win-arm64 linux-x64 linux-arm64 osx-x64 osx-arm64; do | |
| dotnet pack "src/Imazen.WebP.NativeRuntime.${RID}/Imazen.WebP.NativeRuntime.${RID}.csproj" \ | |
| --configuration Release $EXTRA_ARGS -o packages/ | |
| done | |
| dotnet pack src/Imazen.WebP.NativeRuntime.All/Imazen.WebP.NativeRuntime.All.csproj \ | |
| --configuration Release $EXTRA_ARGS -o packages/ | |
| dotnet pack src/Imazen.WebP.AllPlatforms/Imazen.WebP.AllPlatforms.csproj \ | |
| --configuration Release $EXTRA_ARGS -o packages/ | |
| - name: Upload NuGet packages | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: nuget-packages | |
| path: packages/*.nupkg | |
| retention-days: 30 | |
| - name: Publish to NuGet.org | |
| if: >- | |
| github.event_name == 'release' || | |
| (github.event_name == 'workflow_dispatch' && inputs.libwebp_version != '') | |
| env: | |
| NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} | |
| run: | | |
| if [ -z "$NUGET_API_KEY" ]; then | |
| echo "::warning::NUGET_API_KEY secret not set — skipping NuGet publish" | |
| exit 0 | |
| fi | |
| for PKG in packages/*.nupkg; do | |
| dotnet nuget push "$PKG" \ | |
| --api-key "$NUGET_API_KEY" \ | |
| --source https://api.nuget.org/v3/index.json \ | |
| --skip-duplicate | |
| done |