Skip to content

.NET Build, Test & Publish #34

.NET Build, Test & Publish

.NET Build, Test & Publish #34

Workflow file for this run

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