name: staticlink-avalonia on: workflow_dispatch: inputs: skiasharp_version: description: Stable SkiaSharp version from this branch major; empty uses the branch version type: string default: '' native_package_revision: description: Published AvaloniaNative revision for independent graphics smoke tests type: string default: '1' publish_to_nuget: description: Publish package to nuget.org required: true default: false type: boolean publish_to_release: description: Publish NuGet package and smoke apps to GitHub Release required: true default: false type: boolean push: tags: - "staticlink-avalonia-v*" env: DOTNET_ALPINE_IMAGE: mcr.microsoft.com/dotnet/sdk:10.0.302-alpine3.23 concurrency: group: graphics-${{ github.ref }}-${{ inputs.skiasharp_version }} cancel-in-progress: false jobs: preflight: uses: ./.github/workflows/static-graphics-preflight.yml with: skiasharp_version: ${{ inputs.skiasharp_version }} build-zig: permissions: contents: read packages: write needs: preflight uses: ./.github/workflows/static-graphics-zig-cross.yml with: skiasharp_version: ${{ needs.preflight.outputs.skiasharp_version }} source_lock: ${{ needs.preflight.outputs.source_lock }} pack: runs-on: ubuntu-24.04 needs: - preflight - build-zig permissions: contents: read steps: - uses: actions/checkout@v4 - name: Setup .NET uses: actions/setup-dotnet@v4 with: dotnet-version: "10.0.302" - name: Download static archives uses: actions/download-artifact@v4 with: pattern: native-static-* path: External/NativeStatic merge-multiple: false - name: Move static archives into RID directories run: | for rid in win-x64 win-x86 win-arm64 linux-x64 linux-arm64 linux-musl-x64 linux-musl-arm64 osx-x64 osx-arm64; do mkdir -p "External/NativeStatic/$rid" mv "External/NativeStatic/native-static-$rid"/* "External/NativeStatic/$rid/" rmdir "External/NativeStatic/native-static-$rid" if [ ! -d "External/NativeStatic/$rid/native" ]; then mkdir -p "External/NativeStatic/$rid/native" find "External/NativeStatic/$rid" -maxdepth 1 -type f -exec mv {} "External/NativeStatic/$rid/native/" \; fi done - name: Verify static archives run: | for rid in linux-x64 linux-arm64 linux-musl-x64 linux-musl-arm64 osx-x64 osx-arm64; do test -f External/NativeStatic/$rid/native/libskia.a test -f External/NativeStatic/$rid/native/libSkiaSharp.a test -f External/NativeStatic/$rid/native/libHarfBuzzSharp.a test -f External/NativeStatic/$rid/native/libANGLE_static.a test -f External/NativeStatic/$rid/native/libGLESv2_static.a done for rid in win-x64 win-x86 win-arm64; do test -f External/NativeStatic/$rid/native/skia.lib test -f External/NativeStatic/$rid/native/SkiaSharp.lib test -f External/NativeStatic/$rid/native/libHarfBuzzSharp.lib test -f External/NativeStatic/$rid/native/libANGLE_static.lib test -f External/NativeStatic/$rid/native/libGLESv2_static.lib done test -f External/NativeStatic/win-x86/native/skia_x86_stdcall_thunks.lib - name: Pack NuGet package env: SOURCE_LOCK_JSON: ${{ needs.preflight.outputs.source_lock }} PACKAGE_VERSION: ${{ needs.preflight.outputs.package_version }} SKIASHARP_VERSION: ${{ needs.preflight.outputs.skiasharp_version }} run: | python3 scripts/apply-release-version.py dotnet pack NuGet/StaticGraphics/StaticLink.Avalonia.csproj -c Release -o artifacts/nuget - name: Verify portable NuGet paths shell: bash run: | package=$(find artifacts/nuget -name 'StaticLink.Avalonia.*.nupkg' ! -name 'StaticLink.Avalonia.Native.*.nupkg' -print -quit) test -n "$package" if unzip -Z1 "$package" | grep -F '\'; then echo "NuGet package contains Windows-only backslash paths." exit 1 fi unzip -Z1 "$package" | grep -Fx 'buildTransitive/StaticLink.Avalonia.props' unzip -Z1 "$package" | grep -Fx 'buildTransitive/StaticLink.Avalonia.targets' - name: Upload release source lock uses: actions/upload-artifact@v4 with: name: graphics-release-source-lock path: build/linux-toolchain/release.lock.json if-no-files-found: error - name: Upload NuGet artifact uses: actions/upload-artifact@v4 with: name: staticlink-avalonia-nuget path: | artifacts/nuget/*.nupkg artifacts/nuget/*.snupkg if-no-files-found: error smoke-avalonia: needs: pack strategy: fail-fast: false matrix: include: - rid: win-x64 runner: windows-2022 msvc_arch: x64 - rid: win-x86 runner: windows-2022 msvc_arch: x86 - rid: win-arm64 runner: windows-2022 msvc_arch: amd64_arm64 - rid: linux-x64 runner: ubuntu-24.04 - rid: linux-arm64 runner: ubuntu-24.04-arm - rid: linux-musl-x64 runner: ubuntu-24.04 - rid: linux-musl-arm64 runner: ubuntu-24.04-arm - rid: osx-x64 runner: macos-15 avalonia_version: 11.3.14 - rid: osx-arm64 runner: macos-15 avalonia_version: 11.3.14 runs-on: ${{ matrix.runner }} steps: - uses: actions/checkout@v4 - name: Setup MSVC environment if: ${{ startsWith(matrix.rid, 'win-') }} uses: ilammy/msvc-dev-cmd@v1 with: arch: ${{ matrix.msvc_arch }} - name: Install LLVM linker for Zig Windows archives if: ${{ startsWith(matrix.rid, 'win-') }} shell: pwsh run: choco install llvm -y - name: Install Linux publish dependencies if: ${{ startsWith(matrix.rid, 'linux-') && !startsWith(matrix.rid, 'linux-musl-') }} run: | sudo apt-get update sudo apt-get install -y clang lld xvfb zlib1g-dev libfontconfig1-dev libffi-dev libwayland-dev libx11-dev libxext-dev libxcb1-dev libfreetype6 libgl1 libice6 libsm6 libx11-6 libxcb1 libxcursor1 libxext6 libxfixes3 libxinerama1 libxrandr2 libxrender1 libxi6 - name: Setup .NET uses: actions/setup-dotnet@v4 with: dotnet-version: "10.0.302" - name: Download NuGet package uses: actions/download-artifact@v4 with: name: staticlink-avalonia-nuget path: artifacts/nuget - name: Publish Avalonia single-file smoke app if: ${{ !startsWith(matrix.rid, 'linux-musl-') }} shell: pwsh run: | $ErrorActionPreference = "Stop" $package = Get-ChildItem -Path "artifacts/nuget" -Filter "StaticLink.Avalonia.*.nupkg" | Where-Object { $_.Name -notlike "StaticLink.Avalonia.Native.*" } | Select-Object -First 1 if (-not $package) { throw "StaticLink NuGet package was not downloaded." } if ($package.BaseName -notmatch '^StaticLink\.Avalonia\.(.+)$') { throw "Unexpected package name: $($package.Name)" } $version = $Matches[1] $avaloniaVersion = "${{ matrix.avalonia_version }}" if (-not $avaloniaVersion) { $avaloniaVersion = "11.3.14" } $projectDir = Join-Path $PWD "artifacts/avalonia-smoke-src" $nativeVersion = "" if ("${{ matrix.rid }}".StartsWith("osx-")) { $nativeVersion = "$avaloniaVersion.${{ inputs.native_package_revision || '1' }}" } pwsh ./scripts/create-avalonia-smoke.ps1 -ProjectDir $projectDir -NuGetSource (Resolve-Path "artifacts/nuget") -StaticLinkVersion $version -AvaloniaVersion $avaloniaVersion -StaticLinkNativeVersion $nativeVersion dotnet publish (Join-Path $projectDir "AvaloniaStaticLinkSmoke.csproj") ` -c Release ` -r "${{ matrix.rid }}" ` --configfile (Join-Path $projectDir "NuGet.config") ` /p:PublishAot=true ` /p:SelfContained=true ` /p:PublishSingleFile=true ` /p:StripSymbols=true ` /p:PublishDir="$PWD/artifacts/smoke-${{ matrix.rid }}/" - name: Publish Avalonia musl smoke app if: ${{ startsWith(matrix.rid, 'linux-musl-') }} shell: pwsh run: | $ErrorActionPreference = "Stop" $package = Get-ChildItem -Path "artifacts/nuget" -Filter "StaticLink.Avalonia.*.nupkg" | Where-Object { $_.Name -notlike "StaticLink.Avalonia.Native.*" } | Select-Object -First 1 if (-not $package) { throw "StaticLink NuGet package was not downloaded." } if ($package.BaseName -notmatch '^StaticLink\.Avalonia\.(.+)$') { throw "Unexpected package name: $($package.Name)" } $version = $Matches[1] $projectDir = Join-Path $PWD "artifacts/avalonia-smoke-src" pwsh ./scripts/create-avalonia-smoke.ps1 -ProjectDir $projectDir -NuGetSource "/workspace/artifacts/nuget" -StaticLinkVersion $version -AvaloniaVersion "11.3.14" docker run --rm ` -v "${{ github.workspace }}:/workspace" ` -w /workspace ` "${{ env.DOTNET_ALPINE_IMAGE }}" ` sh -c "apk add --no-cache clang lld build-base zlib-dev fontconfig-dev libffi-dev wayland-dev libx11-dev libxext-dev libxcb-dev && dotnet publish /workspace/artifacts/avalonia-smoke-src/AvaloniaStaticLinkSmoke.csproj -c Release -r ${{ matrix.rid }} --configfile /workspace/artifacts/avalonia-smoke-src/NuGet.config /p:PublishAot=true /p:SelfContained=true /p:PublishSingleFile=true /p:StripSymbols=true /p:PublishDir=/workspace/artifacts/smoke-${{ matrix.rid }}/" - name: Verify no dynamic graphics libraries remain shell: pwsh run: | $publishDir = "artifacts/smoke-${{ matrix.rid }}" $blocked = Get-ChildItem -Path $publishDir -Recurse -File | Where-Object { $_.Name -in @("libSkiaSharp.dll", "libHarfBuzzSharp.dll", "av_libglesv2.dll", "libSkiaSharp.so", "libHarfBuzzSharp.so", "libSkiaSharp.dylib", "libHarfBuzzSharp.dylib") } if ($blocked) { $blocked | ForEach-Object { Write-Error "Unexpected dynamic graphics library: $($_.FullName)" } exit 1 } Get-ChildItem -Path $publishDir -Recurse -File | ForEach-Object { $_.FullName } - name: Verify Windows executable architecture if: ${{ startsWith(matrix.rid, 'win-') }} shell: pwsh run: | $ErrorActionPreference = "Stop" $app = Join-Path $PWD "artifacts/smoke-${{ matrix.rid }}/AvaloniaStaticLinkSmoke.exe" $expectedMachine = @{ "win-x64" = "8664 machine" "win-x86" = "14C machine" "win-arm64" = "AA64 machine" }["${{ matrix.rid }}"] $headers = & dumpbin /headers $app | Out-String if ($LASTEXITCODE -ne 0 -or $headers -notmatch [regex]::Escape($expectedMachine)) { throw "Expected $expectedMachine in PE headers for $app.`n$headers" } - name: Run Windows Avalonia smoke app if: ${{ matrix.rid == 'win-x64' || matrix.rid == 'win-x86' }} shell: pwsh run: | $ErrorActionPreference = "Stop" $publishDir = Join-Path $PWD "artifacts/smoke-${{ matrix.rid }}" $app = Join-Path $publishDir "AvaloniaStaticLinkSmoke.exe" $process = Start-Process -FilePath $app -WorkingDirectory $publishDir -PassThru if ($process.WaitForExit(15000)) { if ($process.ExitCode -eq 0) { Write-Host "Avalonia Windows smoke app exited normally." exit 0 } throw "Avalonia Windows smoke app failed with exit code $($process.ExitCode)." } Stop-Process -Id $process.Id -Force Write-Host "Avalonia Windows smoke app stayed alive for 15 seconds." - name: Run Linux Avalonia smoke app if: ${{ startsWith(matrix.rid, 'linux-') && !startsWith(matrix.rid, 'linux-musl-') }} shell: bash run: | set +e cd "artifacts/smoke-${{ matrix.rid }}" chmod +x ./AvaloniaStaticLinkSmoke timeout 15s xvfb-run -a ./AvaloniaStaticLinkSmoke code=$? if [ "$code" = 124 ] || [ "$code" = 143 ]; then echo "Avalonia Linux smoke app stayed alive for 15 seconds." exit 0 fi exit "$code" - name: Verify musl ELF dependencies if: ${{ startsWith(matrix.rid, 'linux-musl-') }} shell: bash run: | set -euo pipefail docker run --rm \ -v "${{ github.workspace }}:/workspace" \ -w /workspace \ alpine:3.23 \ sh -c 'apk add --no-cache binutils pax-utils && app="artifacts/smoke-${{ matrix.rid }}/AvaloniaStaticLinkSmoke" && readelf -l "$app" | grep "Requesting program interpreter: /lib/ld-musl-" && scanelf -n "$app" && ! scanelf -n "$app" | grep -E "ld-linux|ld64.so"' - name: Run Linux musl Avalonia smoke app if: ${{ startsWith(matrix.rid, 'linux-musl-') }} shell: bash run: | set -euo pipefail docker run --rm \ -v "${{ github.workspace }}:/workspace" \ -w /workspace/artifacts/smoke-${{ matrix.rid }} \ alpine:3.23 \ sh -c 'apk add --no-cache xvfb-run libstdc++ libgcc fontconfig freetype ttf-dejavu libx11 libxcb libxext libxrender libxi libxrandr libxcursor libxfixes libxinerama libice libsm mesa-gl mesa-egl && fc-cache -f && fc-match sans && chmod +x ./AvaloniaStaticLinkSmoke && timeout 15s xvfb-run -a ./AvaloniaStaticLinkSmoke || code=$?; if [ "${code:-0}" = 124 ] || [ "${code:-0}" = 143 ]; then echo "Avalonia musl smoke app stayed alive for 15 seconds."; exit 0; fi; exit "${code:-0}"' - name: Run macOS Avalonia smoke app if: ${{ startsWith(matrix.rid, 'osx-') }} shell: bash run: | set -euo pipefail cd "artifacts/smoke-${{ matrix.rid }}" chmod +x ./AvaloniaStaticLinkSmoke python3 - <<'PY' import pathlib import subprocess import sys log = pathlib.Path("avalonia-smoke-run.log") proc = subprocess.Popen( ["./AvaloniaStaticLinkSmoke"], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, ) try: output, _ = proc.communicate(timeout=15) except subprocess.TimeoutExpired: proc.kill() output, _ = proc.communicate() log.write_text(output or "") print(output or "") print("Avalonia smoke app stayed alive for 15 seconds.") sys.exit(0) log.write_text(output or "") print(output or "") if proc.returncode == 0: print("Avalonia smoke app exited normally.") sys.exit(0) print(f"Avalonia smoke app failed with exit code {proc.returncode}.", file=sys.stderr) sys.exit(proc.returncode if proc.returncode > 0 else 1) PY - name: Upload Avalonia smoke app if: ${{ always() }} uses: actions/upload-artifact@v4 with: name: avalonia-smoke-${{ matrix.rid }} path: artifacts/smoke-${{ matrix.rid }}/** if-no-files-found: warn smoke-macos-metal: runs-on: macos-15 needs: pack permissions: contents: read steps: - uses: actions/checkout@v4 - name: Setup .NET uses: actions/setup-dotnet@v4 with: dotnet-version: "10.0.302" - name: Download NuGet package uses: actions/download-artifact@v4 with: name: staticlink-avalonia-nuget path: artifacts/nuget - name: Publish Avalonia 12 macOS arm64 smoke app shell: pwsh run: | $ErrorActionPreference = "Stop" $package = Get-ChildItem -Path "artifacts/nuget" -Filter "StaticLink.Avalonia.*.nupkg" | Where-Object { $_.Name -notlike "StaticLink.Avalonia.Native.*" } | Select-Object -First 1 if (-not $package) { throw "StaticLink NuGet package was not downloaded." } if ($package.BaseName -notmatch '^StaticLink\.Avalonia\.(.+)$') { throw "Unexpected package name: $($package.Name)" } $projectDir = Join-Path $PWD "artifacts/avalonia-metal-smoke-src" pwsh ./scripts/create-avalonia-smoke.ps1 ` -ProjectDir $projectDir ` -NuGetSource (Resolve-Path "artifacts/nuget") ` -StaticLinkVersion $Matches[1] ` -AvaloniaVersion "12.1.0" ` -StaticLinkNativeVersion "12.1.0.${{ inputs.native_package_revision || '1' }}" dotnet publish (Join-Path $projectDir "AvaloniaStaticLinkSmoke.csproj") ` -c Release ` -r osx-arm64 ` --configfile (Join-Path $projectDir "NuGet.config") ` /p:PublishAot=true ` /p:SelfContained=true ` /p:PublishSingleFile=true ` /p:StripSymbols=true ` /p:PublishDir="$PWD/artifacts/smoke-osx-arm64-metal/" - name: Verify fully static third-party libraries shell: bash run: | set -euo pipefail publish_dir="artifacts/smoke-osx-arm64-metal" test -x "$publish_dir/AvaloniaStaticLinkSmoke" for library in libAvaloniaNative.dylib libSkiaSharp.dylib libHarfBuzzSharp.dylib; do test ! -f "$publish_dir/$library" done - name: Run default and Metal-only smoke tests shell: bash run: | set -euo pipefail cd artifacts/smoke-osx-arm64-metal python3 - <<'PY' import os import pathlib import subprocess for renderer in ("default", "metal"): environment = os.environ.copy() environment["STATICLINK_MAC_RENDERER"] = renderer environment["STATICLINK_SMOKE_AUTOCLOSE"] = "1" try: result = subprocess.run( ["./AvaloniaStaticLinkSmoke"], env=environment, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, timeout=15, ) except subprocess.TimeoutExpired as exception: output = exception.stdout or "" if isinstance(output, bytes): output = output.decode(errors="replace") pathlib.Path(f"avalonia-{renderer}.log").write_text(output) raise SystemExit(f"Avalonia {renderer} smoke app timed out.\n{output}") output = result.stdout or "" pathlib.Path(f"avalonia-{renderer}.log").write_text(output) print(output, end="") marker = f"STATICLINK_SMOKE_READY={renderer}" if result.returncode != 0 or marker not in output.splitlines(): raise SystemExit(f"Avalonia {renderer} smoke app failed with exit code {result.returncode}.") PY - name: Upload macOS Metal smoke app if: ${{ always() }} uses: actions/upload-artifact@v4 with: name: avalonia-smoke-osx-arm64-metal path: artifacts/smoke-osx-arm64-metal/** if-no-files-found: warn publish-nuget: permissions: contents: read id-token: write runs-on: ubuntu-24.04 needs: - pack - smoke-avalonia - smoke-macos-metal steps: - name: Setup .NET uses: actions/setup-dotnet@v4 with: dotnet-version: "10.0.302" - name: Download NuGet package uses: actions/download-artifact@v4 with: name: staticlink-avalonia-nuget path: artifacts/nuget - name: NuGet Trusted Publishing login id: login if: ${{ vars.NUGET_USER != '' && (startsWith(github.ref, 'refs/tags/staticlink-avalonia-v') || inputs.publish_to_nuget == true) }} uses: NuGet/login@v1 with: user: ${{ vars.NUGET_USER }} - name: Publish to nuget.org if: ${{ startsWith(github.ref, 'refs/tags/staticlink-avalonia-v') || (github.event_name == 'workflow_dispatch' && inputs.publish_to_nuget == true) }} env: NUGET_API_KEY: ${{ steps.login.outputs.NUGET_API_KEY || secrets.NUGET_API_KEY }} run: | if [ -z "$NUGET_API_KEY" ]; then echo "NUGET_API_KEY secret is not configured." >&2 exit 1 fi for package in artifacts/nuget/*.nupkg; do dotnet nuget push "$package" \ --source "https://api.nuget.org/v3/index.json" \ --api-key "$NUGET_API_KEY" \ --skip-duplicate done release: runs-on: ubuntu-24.04 needs: - preflight - pack - smoke-avalonia - smoke-macos-metal - publish-nuget if: ${{ startsWith(github.ref, 'refs/tags/staticlink-avalonia-v') || (github.event_name == 'workflow_dispatch' && inputs.publish_to_release == true) }} permissions: contents: write steps: - uses: actions/checkout@v4 - name: Download NuGet artifact uses: actions/download-artifact@v4 with: name: staticlink-avalonia-nuget path: release-artifacts/staticlink-avalonia-nuget - name: Download release source lock uses: actions/download-artifact@v4 with: name: graphics-release-source-lock path: release-artifacts/source-lock - name: Download smoke artifacts uses: actions/download-artifact@v4 with: pattern: avalonia-smoke-* path: release-artifacts merge-multiple: false - name: Prepare release files run: | mkdir -p release-files cp release-artifacts/source-lock/release.lock.json release-files/ find release-artifacts/staticlink-avalonia-nuget -type f \( -name '*.nupkg' -o -name '*.snupkg' \) -exec cp {} release-files/ \; for dir in release-artifacts/avalonia-smoke-*; do [ -d "$dir" ] || continue name="$(basename "$dir")" (cd "$dir" && zip -r "../../release-files/$name.zip" .) done ls -la release-files - name: Publish GitHub Release env: GH_TOKEN: ${{ github.token }} PACKAGE_VERSION: ${{ needs.preflight.outputs.package_version }} run: | if [ "${GITHUB_REF_TYPE}" = "tag" ]; then release_tag="${GITHUB_REF_NAME}" title="${GITHUB_REF_NAME}" else release_tag="staticlink-avalonia-v${PACKAGE_VERSION}" title="StaticLink.Avalonia ${PACKAGE_VERSION}" fi if gh release view "$release_tag" >/dev/null 2>&1; then gh release upload "$release_tag" release-files/* --clobber else gh release create "$release_tag" release-files/* \ --target "$GITHUB_SHA" \ --title "$title" \ --notes "NuGet package plus Avalonia 11.3.14 NativeAOT single-file smoke apps for win-x64, win-x86, win-arm64, linux-x64, linux-arm64, linux-musl-x64, linux-musl-arm64, osx-x64, and osx-arm64." fi