From 98c2219a22999a4c4247e278828903fa201b7507 Mon Sep 17 00:00:00 2001 From: chavic Date: Thu, 2 Jul 2026 16:24:29 +0200 Subject: [PATCH 01/12] Prepare C# NuGet package workflow Replace the draft nuspec package with a packable SDK-style project that emits a public managed assembly and RID-specific native assets, following the .NET guidance for packages that ship native libraries. Add release-profile native asset scripts, package smoke validation in a clean sample app, and CI jobs that build, pack, and install the preview package across the supported RID matrix: linux-arm64, linux-x64, osx-arm64, osx-x64, win-arm64, and win-x64. --- .github/workflows/csharp.yml | 145 +++++++++++++++++- payjoin-ffi/csharp/.gitignore | 1 + payjoin-ffi/csharp/Directory.Build.props | 7 + payjoin-ffi/csharp/Payjoin.Http.cs | 2 +- payjoin-ffi/csharp/Payjoin.Tests.csproj | 6 + payjoin-ffi/csharp/Payjoin.csproj | 59 +++++++ payjoin-ffi/csharp/Payjoin.nuspec | 28 ---- payjoin-ffi/csharp/README.md | 128 +++++++++------- payjoin-ffi/csharp/contrib/test.sh | 2 +- .../csharp/scripts/build_nuget_native.ps1 | 73 +++++++++ .../csharp/scripts/build_nuget_native.sh | 75 +++++++++ .../csharp/scripts/generate_bindings.ps1 | 18 ++- .../csharp/scripts/generate_bindings.sh | 14 +- .../csharp/scripts/smoke_nuget_package.sh | 80 ++++++++++ payjoin-ffi/uniffi.toml | 1 + 15 files changed, 542 insertions(+), 97 deletions(-) create mode 100644 payjoin-ffi/csharp/Directory.Build.props create mode 100644 payjoin-ffi/csharp/Payjoin.csproj delete mode 100644 payjoin-ffi/csharp/Payjoin.nuspec create mode 100644 payjoin-ffi/csharp/scripts/build_nuget_native.ps1 create mode 100755 payjoin-ffi/csharp/scripts/build_nuget_native.sh create mode 100755 payjoin-ffi/csharp/scripts/smoke_nuget_package.sh diff --git a/.github/workflows/csharp.yml b/.github/workflows/csharp.yml index b4c1817a2..3c0b0f805 100644 --- a/.github/workflows/csharp.yml +++ b/.github/workflows/csharp.yml @@ -60,4 +60,147 @@ jobs: run: ./scripts/generate_bindings.ps1 - name: Run tests - run: dotnet test --logger "console;verbosity=minimal" + run: dotnet test Payjoin.Tests.csproj --logger "console;verbosity=minimal" + + build-nuget-native: + name: "Build NuGet native asset" + runs-on: ${{ matrix.os }} + env: + RUSTUP_TOOLCHAIN: 1.85 + defaults: + run: + working-directory: payjoin-ffi/csharp + strategy: + matrix: + include: + - os: ubuntu-24.04-arm + rid: linux-arm64 + - os: ubuntu-latest + rid: linux-x64 + - os: macos-latest + rid: osx-arm64 + - os: macos-15-intel + rid: osx-x64 + - os: windows-11-arm + rid: win-arm64 + - os: windows-latest + rid: win-x64 + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Install Rust 1.85.0 + uses: dtolnay/rust-toolchain@1.85.0 + + - name: Use cache + uses: Swatinem/rust-cache@v2 + + - name: Build release native asset (unix) + if: "!startsWith(matrix.os, 'windows')" + run: | + PAYJOIN_FFI_RID=${{ matrix.rid }} bash ./scripts/build_nuget_native.sh + + - name: Build release native asset (windows) + if: startsWith(matrix.os, 'windows') + shell: pwsh + run: | + $env:PAYJOIN_FFI_RID = "${{ matrix.rid }}" + ./scripts/build_nuget_native.ps1 + + - name: Upload NuGet native asset + uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.rid }} + path: payjoin-ffi/csharp/artifacts/runtimes/${{ matrix.rid }}/native/* + if-no-files-found: error + + pack-nuget: + name: "Pack C# NuGet package" + runs-on: ubuntu-latest + needs: build-nuget-native + env: + RUSTUP_TOOLCHAIN: 1.85 + defaults: + run: + working-directory: payjoin-ffi/csharp + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Install Rust 1.85.0 + uses: dtolnay/rust-toolchain@1.85.0 + + - name: Use cache + uses: Swatinem/rust-cache@v2 + + - name: Install .NET 10 SDK + uses: actions/setup-dotnet@v4 + with: + dotnet-version: "10.0.x" + + - name: Download native assets + uses: actions/download-artifact@v4 + with: + path: payjoin-ffi/csharp/artifacts/downloaded + + - name: Assemble RID assets + run: | + for rid in linux-arm64 linux-x64 osx-arm64 osx-x64 win-arm64 win-x64; do + mkdir -p "artifacts/runtimes/$rid/native" + cp "artifacts/downloaded/$rid"/* "artifacts/runtimes/$rid/native/" + done + + - name: Generate production bindings + run: | + PAYJOIN_FFI_FEATURES= PAYJOIN_FFI_PROFILE=release bash ./scripts/generate_bindings.sh + + - name: Pack NuGet package + run: dotnet pack Payjoin.csproj --configuration Release --output artifacts/packages + + - name: Upload NuGet package + uses: actions/upload-artifact@v4 + with: + name: payjoin-csharp-nuget-package + path: payjoin-ffi/csharp/artifacts/packages/*.nupkg + if-no-files-found: error + + smoke-nuget: + name: "Smoke test C# NuGet package" + runs-on: ${{ matrix.os }} + needs: pack-nuget + defaults: + run: + working-directory: payjoin-ffi/csharp + strategy: + matrix: + include: + - os: ubuntu-24.04-arm + rid: linux-arm64 + - os: ubuntu-latest + rid: linux-x64 + - os: macos-latest + rid: osx-arm64 + - os: macos-15-intel + rid: osx-x64 + - os: windows-11-arm + rid: win-arm64 + - os: windows-latest + rid: win-x64 + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Install .NET 10 SDK + uses: actions/setup-dotnet@v4 + with: + dotnet-version: "10.0.x" + + - name: Download NuGet package + uses: actions/download-artifact@v4 + with: + name: payjoin-csharp-nuget-package + path: payjoin-ffi/csharp/artifacts/packages + + - name: Run package smoke test + shell: bash + run: bash ./scripts/smoke_nuget_package.sh artifacts/packages 0.24.0-preview.1 ${{ matrix.rid }} diff --git a/payjoin-ffi/csharp/.gitignore b/payjoin-ffi/csharp/.gitignore index deed121f5..95fe7098f 100644 --- a/payjoin-ffi/csharp/.gitignore +++ b/payjoin-ffi/csharp/.gitignore @@ -4,6 +4,7 @@ src/*.cs # Build outputs bin/ obj/ +artifacts/ # IDE .vs/ diff --git a/payjoin-ffi/csharp/Directory.Build.props b/payjoin-ffi/csharp/Directory.Build.props new file mode 100644 index 000000000..6bfb052b4 --- /dev/null +++ b/payjoin-ffi/csharp/Directory.Build.props @@ -0,0 +1,7 @@ + + + obj/$(MSBuildProjectName)/ + bin/$(MSBuildProjectName)/ + $(DefaultItemExcludes);obj/**;bin/**;artifacts/** + + diff --git a/payjoin-ffi/csharp/Payjoin.Http.cs b/payjoin-ffi/csharp/Payjoin.Http.cs index 0ce5136ca..33af61489 100644 --- a/payjoin-ffi/csharp/Payjoin.Http.cs +++ b/payjoin-ffi/csharp/Payjoin.Http.cs @@ -6,7 +6,7 @@ namespace Payjoin.Http { - internal sealed class OhttpKeysClient : IDisposable + public sealed class OhttpKeysClient : IDisposable { private readonly HttpClient _client; private readonly HttpClientHandler _handler; diff --git a/payjoin-ffi/csharp/Payjoin.Tests.csproj b/payjoin-ffi/csharp/Payjoin.Tests.csproj index 41481ab97..93025616d 100644 --- a/payjoin-ffi/csharp/Payjoin.Tests.csproj +++ b/payjoin-ffi/csharp/Payjoin.Tests.csproj @@ -16,6 +16,12 @@ all + + + + + + diff --git a/payjoin-ffi/csharp/Payjoin.csproj b/payjoin-ffi/csharp/Payjoin.csproj new file mode 100644 index 000000000..7532e914d --- /dev/null +++ b/payjoin-ffi/csharp/Payjoin.csproj @@ -0,0 +1,59 @@ + + + + net10.0 + enable + enable + true + true + Payjoin + 0.24.0-preview.1 + Payjoin + Payjoin Dev Kit Contributors + C# bindings for payjoin-ffi, generated from rust-payjoin via UniFFI. + bitcoin;payjoin;bip78;ffi;csharp;dotnet + https://github.com/payjoin/rust-payjoin + https://github.com/payjoin/rust-payjoin + git + MIT OR Apache-2.0 + README.md + false + false + false + true + + $(NoWarn);CS0108;CS0659 + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/payjoin-ffi/csharp/Payjoin.nuspec b/payjoin-ffi/csharp/Payjoin.nuspec deleted file mode 100644 index 7ffd37c32..000000000 --- a/payjoin-ffi/csharp/Payjoin.nuspec +++ /dev/null @@ -1,28 +0,0 @@ - - - - Payjoin - 0.24.0 - Payjoin - Payjoin Dev Kit Contributors - payjoin - false - MIT OR Apache-2.0 - README.md - https://github.com/payjoin/rust-payjoin - - C# bindings for payjoin-ffi, generated from rust-payjoin via UniFFI. - bitcoin payjoin bip78 ffi csharp dotnet - - - - - - - - - - - - - diff --git a/payjoin-ffi/csharp/README.md b/payjoin-ffi/csharp/README.md index 9c1c19f38..21a0eab0a 100644 --- a/payjoin-ffi/csharp/README.md +++ b/payjoin-ffi/csharp/README.md @@ -1,102 +1,112 @@ # Payjoin C# Bindings -Welcome to the C# language bindings for the [Payjoin Dev Kit](https://payjoindevkit.org/)! +C# bindings for the [Payjoin Dev Kit](https://payjoindevkit.org/), generated from +`payjoin-ffi` with UniFFI. -## Running Tests +The NuGet package is still prepared as a preview while the C# API stabilizes. The +first release-ready package layout targets .NET 10 and ships a managed +`Payjoin.dll` plus RID-specific native `payjoin_ffi` libraries. -Follow these steps to clone the repository and run the tests. - -With nix, the C# development shell provides the Rust toolchain and .NET 10 SDK: +## Install ```shell -nix develop .#csharp -c bash payjoin-ffi/csharp/contrib/test.sh +dotnet add package Payjoin --prerelease ``` -```shell -git clone https://github.com/payjoin/rust-payjoin.git -cd rust-payjoin/payjoin-ffi/csharp +## Requirements -# Generate the bindings -bash ./scripts/generate_bindings.sh +- .NET 10.0 or higher +- A supported RID native asset in the package -# Build the project -dotnet build +The first preview release matrix is: -# Run all tests -dotnet test -``` +| OS | RID | Native library | +| ------------------- | ------------- | ---------------------- | +| Linux arm64 | `linux-arm64` | `libpayjoin_ffi.so` | +| Linux x64 | `linux-x64` | `libpayjoin_ffi.so` | +| macOS Apple Silicon | `osx-arm64` | `libpayjoin_ffi.dylib` | +| macOS x64 | `osx-x64` | `libpayjoin_ffi.dylib` | +| Windows arm64 | `win-arm64` | `payjoin_ffi.dll` | +| Windows x64 | `win-x64` | `payjoin_ffi.dll` | -### Windows (PowerShell) +The package follows the .NET native asset layout: -```powershell -git clone https://github.com/payjoin/rust-payjoin.git -cd rust-payjoin/payjoin-ffi/csharp +- `ref/net10.0/Payjoin.dll` +- `runtimes/any/lib/net10.0/Payjoin.dll` +- `runtimes/{rid}/native/{native-library}` -# Generate the bindings -powershell -ExecutionPolicy Bypass -File .\scripts\generate_bindings.ps1 +## Minimal Usage -# Build the project -dotnet build +```csharp +var uri = Payjoin.Url.Parse( + "bitcoin:12c6DSiU4Rq3P4ZxziKxzrL5LmMBrzjrJX?amount=1&pj=https://example.com?ciao"); -# Run all tests -dotnet test +Console.WriteLine(uri.AsString()); ``` -### Windows (Git Bash / MSYS) +## Development + +With nix, the C# development shell provides the Rust toolchain and .NET 10 SDK: + +```shell +nix develop .#csharp -c bash payjoin-ffi/csharp/contrib/test.sh +``` + +Without nix, a Rust toolchain (MSRV: 1.85.0 for this repository) and the .NET 10 +SDK are required: ```shell git clone https://github.com/payjoin/rust-payjoin.git cd rust-payjoin/payjoin-ffi/csharp + bash ./scripts/generate_bindings.sh -dotnet build -dotnet test +dotnet build Payjoin.Tests.csproj +dotnet test Payjoin.Tests.csproj ``` -## Requirements +### Windows -- .NET 10.0 or higher -- Rust toolchain (MSRV: 1.85.0 for this repository) -- Cargo will fetch the C# generator from `chavic/uniffi-bindgen-cs` at commit `878a3d269eacce64beadcd336ade0b7c8da09824` (pinned in `payjoin-ffi/Cargo.toml`) +```powershell +git clone https://github.com/payjoin/rust-payjoin.git +cd rust-payjoin/payjoin-ffi/csharp -## Configuration +powershell -ExecutionPolicy Bypass -File .\scripts\generate_bindings.ps1 +dotnet build Payjoin.Tests.csproj +dotnet test Payjoin.Tests.csproj +``` -Generation uses the Cargo-managed C# generator from `payjoin-ffi/Cargo.toml`. +Generation uses the Cargo-managed C# generator pinned in `payjoin-ffi/Cargo.toml`. +By default, development generation enables `_test-utils` to keep parity with the +test suite. Set `PAYJOIN_FFI_FEATURES` to an empty value for production bindings. -By default, generation builds `payjoin-ffi` with `_test-utils` enabled to keep parity with other language test scripts. Override via `PAYJOIN_FFI_FEATURES`. +## Packaging -### Unix shells +Build the release native asset for the current host RID: ```shell -export PAYJOIN_FFI_FEATURES=_test-utils # default behavior -# export PAYJOIN_FFI_FEATURES="" # build without extra features -bash ./scripts/generate_bindings.sh +PAYJOIN_FFI_FEATURES= bash ./scripts/build_nuget_native.sh ``` -### PowerShell +On Windows: ```powershell -$env:PAYJOIN_FFI_FEATURES = "_test-utils" # default behavior -# $env:PAYJOIN_FFI_FEATURES = "" # build without extra features -powershell -ExecutionPolicy Bypass -File .\scripts\generate_bindings.ps1 -dotnet build +$env:PAYJOIN_FFI_FEATURES = "" +powershell -ExecutionPolicy Bypass -File .\scripts\build_nuget_native.ps1 ``` -## NuGet Packaging (Draft) - -`Payjoin.nuspec` is included for packaging the generated C# source plus native library artifacts. - -Before packing, make sure generation has produced: +To pack, gather every supported RID under `artifacts/runtimes/{rid}/native/`, +generate production bindings, and run: -- `src/payjoin.cs` -- `lib/*` (native library for the current platform) - -Note: this packs the native library currently present in `lib/`. For a cross-platform package, build and include native artifacts from each target platform in CI before publishing. +```shell +PAYJOIN_FFI_FEATURES= PAYJOIN_FFI_PROFILE=release bash ./scripts/generate_bindings.sh +dotnet pack Payjoin.csproj --configuration Release --output artifacts/packages +``` -Example: +Validate the package in a clean sample app: -```powershell -powershell -ExecutionPolicy Bypass -File .\scripts\generate_bindings.ps1 -nuget pack .\Payjoin.nuspec -Version 0.24.0 +```shell +bash ./scripts/smoke_nuget_package.sh artifacts/packages 0.24.0-preview.1 linux-x64 ``` -If `nuget` is not installed, install NuGet.CommandLine first (for example via `dotnet tool` or your package manager). +CI performs the package build from release native assets and runs the smoke test +on each supported RID before publishing should be considered. diff --git a/payjoin-ffi/csharp/contrib/test.sh b/payjoin-ffi/csharp/contrib/test.sh index 73cfcf3df..3c25810fa 100755 --- a/payjoin-ffi/csharp/contrib/test.sh +++ b/payjoin-ffi/csharp/contrib/test.sh @@ -7,4 +7,4 @@ echo "==> Generating FFI bindings..." bash ./scripts/generate_bindings.sh echo "==> Running C# tests..." -dotnet test --logger "console;verbosity=minimal" +dotnet test Payjoin.Tests.csproj --logger "console;verbosity=minimal" diff --git a/payjoin-ffi/csharp/scripts/build_nuget_native.ps1 b/payjoin-ffi/csharp/scripts/build_nuget_native.ps1 new file mode 100644 index 000000000..300140e23 --- /dev/null +++ b/payjoin-ffi/csharp/scripts/build_nuget_native.ps1 @@ -0,0 +1,73 @@ +$ErrorActionPreference = "Stop" +$PSNativeCommandUseErrorActionPreference = $true + +function Get-PayjoinArchitecture { + $architecture = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture + switch ($architecture) { + "X64" { "x64"; break } + "Arm64" { "arm64"; break } + default { throw "Unsupported architecture: $architecture" } + } +} + +function Get-PayjoinRid { + $arch = Get-PayjoinArchitecture + if ([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform([System.Runtime.InteropServices.OSPlatform]::Windows)) { + return "win-$arch" + } + if ([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform([System.Runtime.InteropServices.OSPlatform]::OSX)) { + return "osx-$arch" + } + if ([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform([System.Runtime.InteropServices.OSPlatform]::Linux)) { + return "linux-$arch" + } + + throw "Unsupported OS: $([System.Runtime.InteropServices.RuntimeInformation]::OSDescription)" +} + +function Get-NativeLibraryName { + param([Parameter(Mandatory = $true)][string] $Rid) + + if ($Rid.StartsWith("win-")) { + return "payjoin_ffi.dll" + } + if ($Rid.StartsWith("osx-")) { + return "libpayjoin_ffi.dylib" + } + if ($Rid.StartsWith("linux-")) { + return "libpayjoin_ffi.so" + } + + throw "Unsupported RID: $Rid" +} + +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$csharpDir = Resolve-Path (Join-Path $scriptDir "..") +Set-Location $csharpDir + +if ($env:PAYJOIN_FFI_RID) { + $rid = $env:PAYJOIN_FFI_RID +} else { + $rid = Get-PayjoinRid +} + +$libName = Get-NativeLibraryName -Rid $rid + +if ($null -eq $env:PAYJOIN_FFI_FEATURES) { + $env:PAYJOIN_FFI_FEATURES = "" +} +if (-not $env:PAYJOIN_FFI_PROFILE) { + $env:PAYJOIN_FFI_PROFILE = "release" +} + +& (Join-Path $csharpDir "scripts/generate_bindings.ps1") +if ($LASTEXITCODE -ne 0) { + throw "generate_bindings.ps1 failed with exit code $LASTEXITCODE" +} + +$artifactDir = Join-Path $csharpDir "artifacts/runtimes/$rid/native" +Remove-Item $artifactDir -Recurse -Force -ErrorAction SilentlyContinue +New-Item -ItemType Directory -Force -Path $artifactDir | Out-Null +Copy-Item (Join-Path $csharpDir "lib/$libName") (Join-Path $artifactDir $libName) -Force + +Write-Host "Wrote $artifactDir/$libName" diff --git a/payjoin-ffi/csharp/scripts/build_nuget_native.sh b/payjoin-ffi/csharp/scripts/build_nuget_native.sh new file mode 100755 index 000000000..c7cbed0da --- /dev/null +++ b/payjoin-ffi/csharp/scripts/build_nuget_native.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +set -euo pipefail + +detect_arch() { + case "$(uname -m)" in + x86_64 | amd64) + echo "x64" + ;; + arm64 | aarch64) + echo "arm64" + ;; + *) + echo "Unsupported architecture: $(uname -m)" >&2 + exit 1 + ;; + esac +} + +detect_rid() { + local arch + arch=$(detect_arch) + + case "$(uname -s)" in + Linux) + echo "linux-$arch" + ;; + Darwin) + echo "osx-$arch" + ;; + MINGW* | MSYS* | CYGWIN*) + echo "win-$arch" + ;; + *) + echo "Unsupported os: $(uname -s)" >&2 + exit 1 + ;; + esac +} + +native_library_name() { + case "$1" in + linux-*) + echo "libpayjoin_ffi.so" + ;; + osx-*) + echo "libpayjoin_ffi.dylib" + ;; + win-*) + echo "payjoin_ffi.dll" + ;; + *) + echo "Unsupported RID: $1" >&2 + exit 1 + ;; + esac +} + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CSHARP_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +cd "$CSHARP_DIR" + +RID=${PAYJOIN_FFI_RID:-$(detect_rid)} +LIBNAME=$(native_library_name "$RID") + +export PAYJOIN_FFI_FEATURES="${PAYJOIN_FFI_FEATURES-}" +export PAYJOIN_FFI_PROFILE="${PAYJOIN_FFI_PROFILE:-release}" + +bash ./scripts/generate_bindings.sh + +ARTIFACT_DIR="artifacts/runtimes/$RID/native" +rm -rf "$ARTIFACT_DIR" +mkdir -p "$ARTIFACT_DIR" +cp "lib/$LIBNAME" "$ARTIFACT_DIR/$LIBNAME" + +echo "Wrote $ARTIFACT_DIR/$LIBNAME" diff --git a/payjoin-ffi/csharp/scripts/generate_bindings.ps1 b/payjoin-ffi/csharp/scripts/generate_bindings.ps1 index 52e782508..18426dfc5 100644 --- a/payjoin-ffi/csharp/scripts/generate_bindings.ps1 +++ b/payjoin-ffi/csharp/scripts/generate_bindings.ps1 @@ -50,7 +50,19 @@ if ($payjoinFfiFeatures) { $generatorFeatures = "csharp" } -Invoke-Native cargo build --features $generatorFeatures --profile dev -j2 +if ($env:PAYJOIN_FFI_PROFILE) { + $payjoinFfiProfile = $env:PAYJOIN_FFI_PROFILE +} else { + $payjoinFfiProfile = "dev" +} + +if ($payjoinFfiProfile -eq "dev") { + $targetProfileDir = "debug" +} else { + $targetProfileDir = $payjoinFfiProfile +} + +Invoke-Native cargo build --features $generatorFeatures --profile $payjoinFfiProfile -j2 Write-Host "Cleaning csharp/src/ directory..." New-Item -ItemType Directory -Force -Path "csharp/src" | Out-Null @@ -59,7 +71,7 @@ Get-ChildItem "csharp/src" -Filter "*.cs" -ErrorAction SilentlyContinue | Remove $previousUniffiLanguage = $env:UNIFFI_BINDGEN_LANGUAGE $env:UNIFFI_BINDGEN_LANGUAGE = "csharp" try { - Invoke-Native cargo run --features $generatorFeatures --profile dev --bin uniffi-bindgen '--' --library "../target/debug/$libName" --out-dir "csharp/src/" + Invoke-Native cargo run --features $generatorFeatures --profile dev --bin uniffi-bindgen '--' --library "../target/$targetProfileDir/$libName" --out-dir "csharp/src/" } finally { if ($null -eq $previousUniffiLanguage) { @@ -71,6 +83,6 @@ finally { Write-Host "Copying native library..." New-Item -ItemType Directory -Force -Path "csharp/lib" | Out-Null -Copy-Item "../target/debug/$libName" "csharp/lib/$libName" -Force +Copy-Item "../target/$targetProfileDir/$libName" "csharp/lib/$libName" -Force Write-Host "All done!" diff --git a/payjoin-ffi/csharp/scripts/generate_bindings.sh b/payjoin-ffi/csharp/scripts/generate_bindings.sh index 630ab2146..7766d70a0 100755 --- a/payjoin-ffi/csharp/scripts/generate_bindings.sh +++ b/payjoin-ffi/csharp/scripts/generate_bindings.sh @@ -23,13 +23,19 @@ cd "$SCRIPT_DIR/../.." echo "Generating payjoin C#..." # Keep parity with other language test scripts: include _test-utils by default. -PAYJOIN_FFI_FEATURES=${PAYJOIN_FFI_FEATURES:-_test-utils} +PAYJOIN_FFI_FEATURES=${PAYJOIN_FFI_FEATURES-_test-utils} +PAYJOIN_FFI_PROFILE=${PAYJOIN_FFI_PROFILE:-dev} +if [[ $PAYJOIN_FFI_PROFILE == "dev" ]]; then + TARGET_PROFILE_DIR=debug +else + TARGET_PROFILE_DIR=$PAYJOIN_FFI_PROFILE +fi GENERATOR_FEATURES="csharp" if [[ -n $PAYJOIN_FFI_FEATURES ]]; then GENERATOR_FEATURES="$GENERATOR_FEATURES,$PAYJOIN_FFI_FEATURES" fi -cargo build --features "$GENERATOR_FEATURES" --profile dev -j2 +cargo build --features "$GENERATOR_FEATURES" --profile "$PAYJOIN_FFI_PROFILE" -j2 # Clean output directory to prevent duplicate definitions echo "Cleaning csharp/src/ directory..." @@ -38,12 +44,12 @@ rm -f csharp/src/*.cs # Use the Cargo-managed C# generator pinned in payjoin-ffi/Cargo.toml. UNIFFI_BINDGEN_LANGUAGE=csharp cargo run --features "$GENERATOR_FEATURES" --profile dev --bin uniffi-bindgen -- \ - --library ../target/debug/$LIBNAME \ + --library "../target/$TARGET_PROFILE_DIR/$LIBNAME" \ --out-dir csharp/src/ # Copy native library to csharp/lib/ directory for testing echo "Copying native library..." mkdir -p csharp/lib -cp ../target/debug/$LIBNAME csharp/lib/$LIBNAME +cp "../target/$TARGET_PROFILE_DIR/$LIBNAME" "csharp/lib/$LIBNAME" echo "All done!" diff --git a/payjoin-ffi/csharp/scripts/smoke_nuget_package.sh b/payjoin-ffi/csharp/scripts/smoke_nuget_package.sh new file mode 100755 index 000000000..e4f9e4971 --- /dev/null +++ b/payjoin-ffi/csharp/scripts/smoke_nuget_package.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -lt 2 || $# -gt 3 ]]; then + echo "usage: $0 [runtime-id]" >&2 + exit 1 +fi + +PACKAGE_SOURCE=$(cd "$1" && pwd) +PACKAGE_VERSION=$2 +RID=${3:-} +WORKDIR=$(mktemp -d) +trap 'rm -rf "$WORKDIR"' EXIT +export DOTNET_CLI_HOME="$WORKDIR/dotnet-home" + +native_library_name() { + case "$1" in + linux-*) + echo "libpayjoin_ffi.so" + ;; + osx-*) + echo "libpayjoin_ffi.dylib" + ;; + win-*) + echo "payjoin_ffi.dll" + ;; + *) + echo "Unsupported RID: $1" >&2 + exit 1 + ;; + esac +} + +DOTNET_10_SDK_VERSION=$(dotnet --list-sdks | awk '/^10\./ { print $1; exit }') +if [[ -n $DOTNET_10_SDK_VERSION ]]; then + cat >"$WORKDIR/global.json" </dev/null + +dotnet new console --framework net10.0 --output "PayjoinSmoke" --no-restore +dotnet add "PayjoinSmoke/PayjoinSmoke.csproj" package Payjoin \ + --version "$PACKAGE_VERSION" \ + --source "$PACKAGE_SOURCE" \ + --no-restore + +cat >"PayjoinSmoke/Program.cs" <<'EOF' +var uri = Payjoin.Url.Parse("bitcoin:12c6DSiU4Rq3P4ZxziKxzrL5LmMBrzjrJX?amount=1&pj=https://example.com?ciao"); +Console.WriteLine(uri.AsString()); +EOF + +dotnet restore "PayjoinSmoke/PayjoinSmoke.csproj" \ + --source "$PACKAGE_SOURCE" \ + --ignore-failed-sources \ + -p:UseAppHost=false \ + -p:DisableTransitiveFrameworkReferenceDownloads=true +dotnet build "PayjoinSmoke/PayjoinSmoke.csproj" \ + --framework net10.0 \ + --no-restore \ + -p:UseAppHost=false \ + -p:DisableTransitiveFrameworkReferenceDownloads=true + +if [[ -n $RID ]]; then + LIBNAME=$(native_library_name "$RID") + if [[ -z $(find "PayjoinSmoke/bin/Debug/net10.0" -name "$LIBNAME" -print -quit) ]]; then + echo "Expected native asset $LIBNAME was not copied for $RID" >&2 + exit 1 + fi +fi + +dotnet "PayjoinSmoke/bin/Debug/net10.0/PayjoinSmoke.dll" + +popd >/dev/null diff --git a/payjoin-ffi/uniffi.toml b/payjoin-ffi/uniffi.toml index 590e8d5b4..9006539eb 100644 --- a/payjoin-ffi/uniffi.toml +++ b/payjoin-ffi/uniffi.toml @@ -15,3 +15,4 @@ package_name = "payjoin" [bindings.csharp] namespace = "Payjoin" cdylib_name = "payjoin_ffi" +access_modifier = "public" From c26d8864cd4211443f528fba2e694c9f3cf3c41e Mon Sep 17 00:00:00 2001 From: chavic Date: Thu, 2 Jul 2026 16:24:29 +0200 Subject: [PATCH 02/12] Document C# NuGet release workflow Add maintainer documentation covering package versioning and the publish steps to nuget.org, and link it from the package readme. The package version tracks the payjoin-ffi crate version with a preview suffix, and publication uses the CI-built package artifact so releases always contain every supported RID native asset. --- payjoin-ffi/csharp/README.md | 4 +- payjoin-ffi/csharp/RELEASING.md | 122 ++++++++++++++++++++++++++++++++ 2 files changed, 125 insertions(+), 1 deletion(-) create mode 100644 payjoin-ffi/csharp/RELEASING.md diff --git a/payjoin-ffi/csharp/README.md b/payjoin-ffi/csharp/README.md index 21a0eab0a..ebe3e6299 100644 --- a/payjoin-ffi/csharp/README.md +++ b/payjoin-ffi/csharp/README.md @@ -109,4 +109,6 @@ bash ./scripts/smoke_nuget_package.sh artifacts/packages 0.24.0-preview.1 linux- ``` CI performs the package build from release native assets and runs the smoke test -on each supported RID before publishing should be considered. +on each supported RID before publishing should be considered. The maintainer +release and publish workflow is documented in +[`RELEASING.md`](https://github.com/payjoin/rust-payjoin/blob/master/payjoin-ffi/csharp/RELEASING.md). diff --git a/payjoin-ffi/csharp/RELEASING.md b/payjoin-ffi/csharp/RELEASING.md new file mode 100644 index 000000000..7f947726d --- /dev/null +++ b/payjoin-ffi/csharp/RELEASING.md @@ -0,0 +1,122 @@ +# Releasing the Payjoin NuGet Package + +Maintainer documentation for publishing the `Payjoin` package to nuget.org. +Consumer documentation lives in [`README.md`](README.md), which ships as the +package readme. + +## Versioning + +- The package version is set in `Payjoin.csproj` (``). +- It tracks the `payjoin-ffi` crate version from `payjoin-ffi/Cargo.toml`, + with a `-preview.N` suffix while the C# API stabilizes. For example, + `0.24.0-preview.1` packages `payjoin-ffi 0.24.0`. Pre-release suffixes + follow [SemVer], per NuGet's [package versioning] guidance. +- Bump only the `-preview.N` suffix for packaging-only fixes. Bump + `MAJOR.MINOR.PATCH` together with a `payjoin-ffi` version bump. +- The smoke test invocation in `.github/workflows/csharp.yml` pins the same + version string. Update both places in the same commit. + +## Producing a release candidate + +CI is the release path. On every pull request touching `payjoin-ffi/**`, the +`Build and Test CSharp` workflow: + +1. builds release-profile native assets for each supported RID + (`linux-arm64`, `linux-x64`, `osx-arm64`, `osx-x64`, `win-arm64`, + `win-x64`), +2. generates production bindings (no `_test-utils`) and packs the `.nupkg` + with all RID assets, +3. installs the package into a clean console app and runs a smoke test on + each supported RID. + +To cut a candidate, download the `payjoin-csharp-nuget-package` artifact from +the workflow run on the release commit in `master`. Do not pack from a +development machine for publication; a local pack only contains the native +assets present on that host. + +## Release readiness checklist + +Review before every publish to nuget.org. Grounded in the NuGet +[publish guide], [package authoring best practices], and +[native library packaging] documentation. + +### Package correctness + +- [ ] Every job of the `Build and Test CSharp` workflow is green on the + release commit, including the per-RID smoke tests. +- [ ] `unzip -l Payjoin..nupkg` shows the expected layout: + - `README.md` + - `ref/net10.0/Payjoin.dll` + - `runtimes/any/lib/net10.0/Payjoin.dll` + - `runtimes/linux-arm64/native/libpayjoin_ffi.so` + - `runtimes/linux-x64/native/libpayjoin_ffi.so` + - `runtimes/osx-arm64/native/libpayjoin_ffi.dylib` + - `runtimes/osx-x64/native/libpayjoin_ffi.dylib` + - `runtimes/win-arm64/native/payjoin_ffi.dll` + - `runtimes/win-x64/native/payjoin_ffi.dll` +- [ ] Native assets are release-profile builds without `_test-utils` (the + pack step's validation target enforces both; confirm it ran in CI). +- [ ] The package is under nuget.org's 250 MB size limit. +- [ ] Package version matches `payjoin-ffi`'s crate version plus the intended + pre-release suffix, in both `Payjoin.csproj` and the workflow smoke + step. + +### Metadata and trust + +- [ ] README renders correctly (verify with nuget.org upload preview or the + [readme preview] guidance) and its install command, support matrix, and + minimal usage are accurate for this version. +- [ ] License expression, project URL, repository URL, and tags are present + and correct in `Payjoin.csproj`. +- [ ] Release notes for this version exist (GitHub release or changelog + entry) and breaking changes are called out — required for any version + that changes the package model consumers depend on. +- [ ] Ownership of the `Payjoin` package ID on nuget.org is confirmed for the + publishing account (nuget.org assigns ownership to the pushing account, + not the `Authors` field). + +### Publish security + +- [ ] The nuget.org publishing account has two-factor authentication enabled. +- [ ] The push uses a scoped API key (push-only, `Payjoin` glob, short + expiry) per [scoped API keys], or the `NUGET_API_KEY` environment + variable (.NET SDK 10.0.300+) so the key never appears in shell + history. + +### Post-publish verification + +- [ ] Package passes nuget.org validation and indexing (usually under 15 + minutes; the confirmation email arrives when it is listed). +- [ ] `dotnet new console && dotnet add package Payjoin --prerelease` + restores, builds, and runs on at least one supported RID from the live + feed. +- [ ] Listing on shows the readme, + license, and repository metadata as intended. +- [ ] Decide whether older versions (for example the `0.0.1` placeholder) + should be unlisted or deprecated now that a real release exists. + +## Publishing + +1. Work through the release readiness checklist above. +2. Push the CI-built package: + + ```shell + dotnet nuget push Payjoin..nupkg \ + --source https://api.nuget.org/v3/index.json \ + --api-key + ``` + +3. Complete the post-publish verification section of the checklist. + +Publishing is intentionally manual while the package is in preview. Only +maintainers with nuget.org ownership of the `Payjoin` package ID can push. +If publishing later moves into CI, prefer nuget.org trusted publishing over +long-lived API keys. + +[SemVer]: https://semver.org/ +[package versioning]: https://learn.microsoft.com/en-us/nuget/concepts/package-versioning +[publish guide]: https://learn.microsoft.com/en-us/nuget/nuget-org/publish-a-package +[package authoring best practices]: https://learn.microsoft.com/en-us/nuget/create-packages/package-authoring-best-practices +[native library packaging]: https://learn.microsoft.com/en-us/nuget/create-packages/native-files-in-net-packages +[readme preview]: https://learn.microsoft.com/en-us/nuget/nuget-org/package-readme-on-nuget-org +[scoped API keys]: https://learn.microsoft.com/en-us/nuget/nuget-org/scoped-api-keys From d242b40743d4d8ed82d70767a98f6b6c843fab7b Mon Sep 17 00:00:00 2001 From: chavic Date: Wed, 15 Jul 2026 14:48:03 +0200 Subject: [PATCH 03/12] csharp: Signal production bindings without an empty env var build_nuget_native.ps1 marked production bindings by setting PAYJOIN_FFI_FEATURES to "", mirroring the bash scripts. On Windows, assigning an empty string to an environment variable removes it, so generate_bindings.ps1 read it back as unset, fell through to the default, and compiled the _test-utils feature into the bindings. That left BitcoindEnv in src/payjoin.cs and failed the production-binding guard during dotnet pack. Pass production intent through an explicit -ProductionBindings switch instead of an empty environment variable, which Windows cannot represent distinctly from unset. An explicitly-set PAYJOIN_FFI_FEATURES is still forwarded. --- payjoin-ffi/csharp/scripts/build_nuget_native.ps1 | 14 ++++++++++---- payjoin-ffi/csharp/scripts/generate_bindings.ps1 | 11 ++++++++++- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/payjoin-ffi/csharp/scripts/build_nuget_native.ps1 b/payjoin-ffi/csharp/scripts/build_nuget_native.ps1 index 300140e23..81d05ec81 100644 --- a/payjoin-ffi/csharp/scripts/build_nuget_native.ps1 +++ b/payjoin-ffi/csharp/scripts/build_nuget_native.ps1 @@ -53,14 +53,20 @@ if ($env:PAYJOIN_FFI_RID) { $libName = Get-NativeLibraryName -Rid $rid -if ($null -eq $env:PAYJOIN_FFI_FEATURES) { - $env:PAYJOIN_FFI_FEATURES = "" -} +# On Windows, assigning "" to an environment variable removes it, so we cannot mark production +# bindings by clearing PAYJOIN_FFI_FEATURES the way the bash script does. When the caller has not +# requested specific features, ask generate_bindings.ps1 for production bindings explicitly; an +# explicitly-set PAYJOIN_FFI_FEATURES is still forwarded through the environment. +$useProductionBindings = $null -eq $env:PAYJOIN_FFI_FEATURES if (-not $env:PAYJOIN_FFI_PROFILE) { $env:PAYJOIN_FFI_PROFILE = "release" } -& (Join-Path $csharpDir "scripts/generate_bindings.ps1") +if ($useProductionBindings) { + & (Join-Path $csharpDir "scripts/generate_bindings.ps1") -ProductionBindings +} else { + & (Join-Path $csharpDir "scripts/generate_bindings.ps1") +} if ($LASTEXITCODE -ne 0) { throw "generate_bindings.ps1 failed with exit code $LASTEXITCODE" } diff --git a/payjoin-ffi/csharp/scripts/generate_bindings.ps1 b/payjoin-ffi/csharp/scripts/generate_bindings.ps1 index 18426dfc5..d4dd1d4f7 100644 --- a/payjoin-ffi/csharp/scripts/generate_bindings.ps1 +++ b/payjoin-ffi/csharp/scripts/generate_bindings.ps1 @@ -1,3 +1,10 @@ +param( + # Force production bindings (no extra cargo features). Windows cannot represent an empty + # environment variable distinctly from an unset one, so callers signal production through + # this switch rather than by setting PAYJOIN_FFI_FEATURES to "". + [switch] $ProductionBindings +) + $ErrorActionPreference = "Stop" $PSNativeCommandUseErrorActionPreference = $true @@ -37,7 +44,9 @@ $payjoinFfiDir = Resolve-Path (Join-Path $scriptDir "..\..") Set-Location $payjoinFfiDir Write-Host "Generating payjoin C#..." -if ($null -ne $env:PAYJOIN_FFI_FEATURES) { +if ($ProductionBindings) { + $payjoinFfiFeatures = "" +} elseif ($null -ne $env:PAYJOIN_FFI_FEATURES) { $payjoinFfiFeatures = $env:PAYJOIN_FFI_FEATURES } else { # Keep parity with other language test scripts: include _test-utils by default. From 1992835b9b6e38664c188b8d72aeb968103ea31a Mon Sep 17 00:00:00 2001 From: chavic Date: Wed, 15 Jul 2026 14:55:33 +0200 Subject: [PATCH 04/12] csharp: Skip binding generation in per-RID native builds The per-RID packaging jobs run generate_bindings, which builds the native library and then regenerates the C# bindings with uniffi-bindgen. Those jobs upload only the native asset; pack-nuget regenerates the production bindings once on Ubuntu and that copy is what ships. The per-RID binding generation is therefore discarded work on every matrix runner. Add a native-only mode (--native-only / -NativeOnly) that builds and copies the native library but skips the uniffi-bindgen step, and use it from build_nuget_native. Callers that need bindings (the pack and test jobs) are unchanged. --- .../csharp/scripts/build_nuget_native.ps1 | 4 +-- .../csharp/scripts/build_nuget_native.sh | 2 +- .../csharp/scripts/generate_bindings.ps1 | 34 +++++++++++-------- .../csharp/scripts/generate_bindings.sh | 25 +++++++++----- 4 files changed, 40 insertions(+), 25 deletions(-) diff --git a/payjoin-ffi/csharp/scripts/build_nuget_native.ps1 b/payjoin-ffi/csharp/scripts/build_nuget_native.ps1 index 81d05ec81..cf73972f0 100644 --- a/payjoin-ffi/csharp/scripts/build_nuget_native.ps1 +++ b/payjoin-ffi/csharp/scripts/build_nuget_native.ps1 @@ -63,9 +63,9 @@ if (-not $env:PAYJOIN_FFI_PROFILE) { } if ($useProductionBindings) { - & (Join-Path $csharpDir "scripts/generate_bindings.ps1") -ProductionBindings + & (Join-Path $csharpDir "scripts/generate_bindings.ps1") -ProductionBindings -NativeOnly } else { - & (Join-Path $csharpDir "scripts/generate_bindings.ps1") + & (Join-Path $csharpDir "scripts/generate_bindings.ps1") -NativeOnly } if ($LASTEXITCODE -ne 0) { throw "generate_bindings.ps1 failed with exit code $LASTEXITCODE" diff --git a/payjoin-ffi/csharp/scripts/build_nuget_native.sh b/payjoin-ffi/csharp/scripts/build_nuget_native.sh index c7cbed0da..eaab6a53c 100755 --- a/payjoin-ffi/csharp/scripts/build_nuget_native.sh +++ b/payjoin-ffi/csharp/scripts/build_nuget_native.sh @@ -65,7 +65,7 @@ LIBNAME=$(native_library_name "$RID") export PAYJOIN_FFI_FEATURES="${PAYJOIN_FFI_FEATURES-}" export PAYJOIN_FFI_PROFILE="${PAYJOIN_FFI_PROFILE:-release}" -bash ./scripts/generate_bindings.sh +bash ./scripts/generate_bindings.sh --native-only ARTIFACT_DIR="artifacts/runtimes/$RID/native" rm -rf "$ARTIFACT_DIR" diff --git a/payjoin-ffi/csharp/scripts/generate_bindings.ps1 b/payjoin-ffi/csharp/scripts/generate_bindings.ps1 index d4dd1d4f7..e022c3a3f 100644 --- a/payjoin-ffi/csharp/scripts/generate_bindings.ps1 +++ b/payjoin-ffi/csharp/scripts/generate_bindings.ps1 @@ -2,7 +2,11 @@ param( # Force production bindings (no extra cargo features). Windows cannot represent an empty # environment variable distinctly from an unset one, so callers signal production through # this switch rather than by setting PAYJOIN_FFI_FEATURES to "". - [switch] $ProductionBindings + [switch] $ProductionBindings, + + # Build the native library without regenerating the C# bindings, for callers (the per-RID + # packaging jobs) that consume only the native asset. + [switch] $NativeOnly ) $ErrorActionPreference = "Stop" @@ -73,20 +77,22 @@ if ($payjoinFfiProfile -eq "dev") { Invoke-Native cargo build --features $generatorFeatures --profile $payjoinFfiProfile -j2 -Write-Host "Cleaning csharp/src/ directory..." -New-Item -ItemType Directory -Force -Path "csharp/src" | Out-Null -Get-ChildItem "csharp/src" -Filter "*.cs" -ErrorAction SilentlyContinue | Remove-Item -Force +if (-not $NativeOnly) { + Write-Host "Cleaning csharp/src/ directory..." + New-Item -ItemType Directory -Force -Path "csharp/src" | Out-Null + Get-ChildItem "csharp/src" -Filter "*.cs" -ErrorAction SilentlyContinue | Remove-Item -Force -$previousUniffiLanguage = $env:UNIFFI_BINDGEN_LANGUAGE -$env:UNIFFI_BINDGEN_LANGUAGE = "csharp" -try { - Invoke-Native cargo run --features $generatorFeatures --profile dev --bin uniffi-bindgen '--' --library "../target/$targetProfileDir/$libName" --out-dir "csharp/src/" -} -finally { - if ($null -eq $previousUniffiLanguage) { - Remove-Item Env:UNIFFI_BINDGEN_LANGUAGE -ErrorAction SilentlyContinue - } else { - $env:UNIFFI_BINDGEN_LANGUAGE = $previousUniffiLanguage + $previousUniffiLanguage = $env:UNIFFI_BINDGEN_LANGUAGE + $env:UNIFFI_BINDGEN_LANGUAGE = "csharp" + try { + Invoke-Native cargo run --features $generatorFeatures --profile dev --bin uniffi-bindgen '--' --library "../target/$targetProfileDir/$libName" --out-dir "csharp/src/" + } + finally { + if ($null -eq $previousUniffiLanguage) { + Remove-Item Env:UNIFFI_BINDGEN_LANGUAGE -ErrorAction SilentlyContinue + } else { + $env:UNIFFI_BINDGEN_LANGUAGE = $previousUniffiLanguage + } } } diff --git a/payjoin-ffi/csharp/scripts/generate_bindings.sh b/payjoin-ffi/csharp/scripts/generate_bindings.sh index 7766d70a0..6fd7af2f0 100755 --- a/payjoin-ffi/csharp/scripts/generate_bindings.sh +++ b/payjoin-ffi/csharp/scripts/generate_bindings.sh @@ -1,6 +1,13 @@ #!/usr/bin/env bash set -euo pipefail +# --native-only builds the native library without regenerating the C# bindings, for callers +# (the per-RID packaging jobs) that consume only the native asset. +NATIVE_ONLY=0 +if [[ ${1:-} == "--native-only" ]]; then + NATIVE_ONLY=1 +fi + OS=$(uname -s) echo "Running on $OS" @@ -37,15 +44,17 @@ fi cargo build --features "$GENERATOR_FEATURES" --profile "$PAYJOIN_FFI_PROFILE" -j2 -# Clean output directory to prevent duplicate definitions -echo "Cleaning csharp/src/ directory..." -mkdir -p csharp/src -rm -f csharp/src/*.cs +if [[ $NATIVE_ONLY -eq 0 ]]; then + # Clean output directory to prevent duplicate definitions + echo "Cleaning csharp/src/ directory..." + mkdir -p csharp/src + rm -f csharp/src/*.cs -# Use the Cargo-managed C# generator pinned in payjoin-ffi/Cargo.toml. -UNIFFI_BINDGEN_LANGUAGE=csharp cargo run --features "$GENERATOR_FEATURES" --profile dev --bin uniffi-bindgen -- \ - --library "../target/$TARGET_PROFILE_DIR/$LIBNAME" \ - --out-dir csharp/src/ + # Use the Cargo-managed C# generator pinned in payjoin-ffi/Cargo.toml. + UNIFFI_BINDGEN_LANGUAGE=csharp cargo run --features "$GENERATOR_FEATURES" --profile dev --bin uniffi-bindgen -- \ + --library "../target/$TARGET_PROFILE_DIR/$LIBNAME" \ + --out-dir csharp/src/ +fi # Copy native library to csharp/lib/ directory for testing echo "Copying native library..." From b4559ed9e21232cc9e11b30368e28485102eb3e2 Mon Sep 17 00:00:00 2001 From: chavic Date: Wed, 15 Jul 2026 20:03:31 +0200 Subject: [PATCH 05/12] csharp: Document the explicit production bindings flow The packaging examples still taught setting PAYJOIN_FFI_FEATURES to an empty string, which Windows cannot express, and the pack sequence had no Windows variant at all. Document -ProductionBindings for PowerShell, drop the now-redundant empty assignments (production is the default for build_nuget_native when the variable is unset), and note that the native-asset step no longer regenerates bindings. --- payjoin-ffi/csharp/Payjoin.csproj | 2 +- payjoin-ffi/csharp/README.md | 19 +++++++++++++++---- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/payjoin-ffi/csharp/Payjoin.csproj b/payjoin-ffi/csharp/Payjoin.csproj index 7532e914d..77dbded8b 100644 --- a/payjoin-ffi/csharp/Payjoin.csproj +++ b/payjoin-ffi/csharp/Payjoin.csproj @@ -53,7 +53,7 @@ bindings means they were not generated for production. --> + Text="Generated bindings include _test-utils. Regenerate production bindings before packing (bash: PAYJOIN_FFI_FEATURES= ./scripts/generate_bindings.sh; PowerShell: ./scripts/generate_bindings.ps1 -ProductionBindings)." /> diff --git a/payjoin-ffi/csharp/README.md b/payjoin-ffi/csharp/README.md index ebe3e6299..e12ee6095 100644 --- a/payjoin-ffi/csharp/README.md +++ b/payjoin-ffi/csharp/README.md @@ -77,20 +77,23 @@ dotnet test Payjoin.Tests.csproj Generation uses the Cargo-managed C# generator pinned in `payjoin-ffi/Cargo.toml`. By default, development generation enables `_test-utils` to keep parity with the -test suite. Set `PAYJOIN_FFI_FEATURES` to an empty value for production bindings. +test suite. For production bindings, set `PAYJOIN_FFI_FEATURES` to an empty value +(bash) or pass `-ProductionBindings` (PowerShell — Windows cannot represent an +empty environment variable, so the switch is the only reliable signal there). ## Packaging -Build the release native asset for the current host RID: +Build the release native asset for the current host RID (production features are +the default when `PAYJOIN_FFI_FEATURES` is not set; this step does not regenerate +the C# bindings): ```shell -PAYJOIN_FFI_FEATURES= bash ./scripts/build_nuget_native.sh +bash ./scripts/build_nuget_native.sh ``` On Windows: ```powershell -$env:PAYJOIN_FFI_FEATURES = "" powershell -ExecutionPolicy Bypass -File .\scripts\build_nuget_native.ps1 ``` @@ -102,6 +105,14 @@ PAYJOIN_FFI_FEATURES= PAYJOIN_FFI_PROFILE=release bash ./scripts/generate_bindin dotnet pack Payjoin.csproj --configuration Release --output artifacts/packages ``` +On Windows: + +```powershell +$env:PAYJOIN_FFI_PROFILE = "release" +powershell -ExecutionPolicy Bypass -File .\scripts\generate_bindings.ps1 -ProductionBindings +dotnet pack Payjoin.csproj --configuration Release --output artifacts/packages +``` + Validate the package in a clean sample app: ```shell From 0db5be8e18132d769b202dd612677e6a33ce503e Mon Sep 17 00:00:00 2001 From: chavic Date: Wed, 15 Jul 2026 20:11:59 +0200 Subject: [PATCH 06/12] csharp: Scope package analysis suppression to NU5131 NoPackageAnalysis=true silenced every package-analysis warning. Packing with analysis enabled shows exactly one: NU5131, raised because the package ships a ref/net10.0 assembly without a nuspec references group, which csproj-based pack cannot emit. That ref/runtimes split is the intentional native-asset layout and consumption is covered by the per-RID smoke tests, so suppress NU5131 alone and keep future packaging regressions visible. --- payjoin-ffi/csharp/Payjoin.csproj | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/payjoin-ffi/csharp/Payjoin.csproj b/payjoin-ffi/csharp/Payjoin.csproj index 77dbded8b..5dd9357ad 100644 --- a/payjoin-ffi/csharp/Payjoin.csproj +++ b/payjoin-ffi/csharp/Payjoin.csproj @@ -20,11 +20,15 @@ false false false - true - $(NoWarn);CS0108;CS0659 + Equals without GetHashCode). Regenerate rather than hand-edit src/payjoin.cs. + NU5131 fires because the package ships a ref/net10.0 assembly (the intentional + native-asset layout: compile against ref/, run from runtimes/) and csproj-based + pack cannot emit the nuspec references group the analysis asks for; consumption + is covered by the per-RID smoke tests. Keep every other package-analysis warning + visible. --> + $(NoWarn);CS0108;CS0659;NU5131 From d6bfaa216eb4d2db25b293f25d85a4303ccfe748 Mon Sep 17 00:00:00 2001 From: chavic Date: Wed, 15 Jul 2026 20:11:59 +0200 Subject: [PATCH 07/12] csharp: Derive the smoke test version from the packed artifact The package version was maintained in three places: Payjoin.csproj, the workflow smoke invocation, and the documentation examples. The smoke script now accepts 'auto' and derives the version from the single Payjoin.*.nupkg it is pointed at, failing clearly on zero or multiple candidates. CI uses 'auto', so Payjoin.csproj becomes the only place the version is maintained and the smoke test always exercises the artifact that was actually packed. --- .github/workflows/csharp.yml | 4 +++- payjoin-ffi/csharp/README.md | 5 +++-- payjoin-ffi/csharp/RELEASING.md | 9 ++++----- .../csharp/scripts/smoke_nuget_package.sh | 17 ++++++++++++++++- 4 files changed, 26 insertions(+), 9 deletions(-) diff --git a/.github/workflows/csharp.yml b/.github/workflows/csharp.yml index 3c0b0f805..1f0cd7e22 100644 --- a/.github/workflows/csharp.yml +++ b/.github/workflows/csharp.yml @@ -203,4 +203,6 @@ jobs: - name: Run package smoke test shell: bash - run: bash ./scripts/smoke_nuget_package.sh artifacts/packages 0.24.0-preview.1 ${{ matrix.rid }} + # 'auto' derives the version from the packed artifact, so Payjoin.csproj + # stays the only place the package version is maintained. + run: bash ./scripts/smoke_nuget_package.sh artifacts/packages auto ${{ matrix.rid }} diff --git a/payjoin-ffi/csharp/README.md b/payjoin-ffi/csharp/README.md index e12ee6095..218c80120 100644 --- a/payjoin-ffi/csharp/README.md +++ b/payjoin-ffi/csharp/README.md @@ -113,10 +113,11 @@ powershell -ExecutionPolicy Bypass -File .\scripts\generate_bindings.ps1 -Produc dotnet pack Payjoin.csproj --configuration Release --output artifacts/packages ``` -Validate the package in a clean sample app: +Validate the package in a clean sample app (`auto` derives the version from the +packed artifact; an explicit version is also accepted): ```shell -bash ./scripts/smoke_nuget_package.sh artifacts/packages 0.24.0-preview.1 linux-x64 +bash ./scripts/smoke_nuget_package.sh artifacts/packages auto linux-x64 ``` CI performs the package build from release native assets and runs the smoke test diff --git a/payjoin-ffi/csharp/RELEASING.md b/payjoin-ffi/csharp/RELEASING.md index 7f947726d..fc86cad12 100644 --- a/payjoin-ffi/csharp/RELEASING.md +++ b/payjoin-ffi/csharp/RELEASING.md @@ -13,8 +13,8 @@ package readme. follow [SemVer], per NuGet's [package versioning] guidance. - Bump only the `-preview.N` suffix for packaging-only fixes. Bump `MAJOR.MINOR.PATCH` together with a `payjoin-ffi` version bump. -- The smoke test invocation in `.github/workflows/csharp.yml` pins the same - version string. Update both places in the same commit. +- `Payjoin.csproj` is the only place the version is maintained: the CI smoke + test derives the version from the packed artifact. ## Producing a release candidate @@ -57,9 +57,8 @@ Review before every publish to nuget.org. Grounded in the NuGet - [ ] Native assets are release-profile builds without `_test-utils` (the pack step's validation target enforces both; confirm it ran in CI). - [ ] The package is under nuget.org's 250 MB size limit. -- [ ] Package version matches `payjoin-ffi`'s crate version plus the intended - pre-release suffix, in both `Payjoin.csproj` and the workflow smoke - step. +- [ ] Package version in `Payjoin.csproj` matches `payjoin-ffi`'s crate + version plus the intended pre-release suffix. ### Metadata and trust diff --git a/payjoin-ffi/csharp/scripts/smoke_nuget_package.sh b/payjoin-ffi/csharp/scripts/smoke_nuget_package.sh index e4f9e4971..15ba0b52f 100755 --- a/payjoin-ffi/csharp/scripts/smoke_nuget_package.sh +++ b/payjoin-ffi/csharp/scripts/smoke_nuget_package.sh @@ -2,13 +2,28 @@ set -euo pipefail if [[ $# -lt 2 || $# -gt 3 ]]; then - echo "usage: $0 [runtime-id]" >&2 + echo "usage: $0 [runtime-id]" >&2 + echo " 'auto' derives the version from the single Payjoin.*.nupkg in ." >&2 exit 1 fi PACKAGE_SOURCE=$(cd "$1" && pwd) PACKAGE_VERSION=$2 RID=${3:-} + +if [[ $PACKAGE_VERSION == auto ]]; then + shopt -s nullglob + packages=("$PACKAGE_SOURCE"/Payjoin.*.nupkg) + shopt -u nullglob + if [[ ${#packages[@]} -ne 1 ]]; then + echo "Expected exactly one Payjoin.*.nupkg in $PACKAGE_SOURCE to derive the version; found ${#packages[@]}." >&2 + exit 1 + fi + PACKAGE_VERSION=$(basename "${packages[0]}") + PACKAGE_VERSION=${PACKAGE_VERSION#Payjoin.} + PACKAGE_VERSION=${PACKAGE_VERSION%.nupkg} + echo "Derived package version $PACKAGE_VERSION from ${packages[0]}" +fi WORKDIR=$(mktemp -d) trap 'rm -rf "$WORKDIR"' EXIT export DOTNET_CLI_HOME="$WORKDIR/dotnet-home" From ad25faf512721332121f743722fc0b748459d2aa Mon Sep 17 00:00:00 2001 From: chavic Date: Wed, 15 Jul 2026 21:21:08 +0200 Subject: [PATCH 08/12] csharp: Cross-compile all native assets from Linux Native assets were built on six runner types across three operating systems. Build every RID from Linux instead: cargo-zigbuild links the Linux targets against a pinned glibc 2.17 floor (the previous builds inherited the runner's glibc, restricting which distributions could load the .so) and the macOS targets against zig's redistributable libSystem stubs, while cargo-xwin links MSVC-ABI Windows DLLs for both architectures against the Microsoft CRT. build_nuget_native.sh gains a PAYJOIN_FFI_CROSS=1 mode mapping each RID to its Rust triple and tool; bindings are never generated in this mode since the pack step owns the packaged bindings. The workflow matrix keeps one job per RID (unchanged artifact names, so the pack job is untouched) but every row now runs on ubuntu-latest. The per-RID smoke jobs still verify every produced asset on real hardware. All six targets were verified locally from a Linux host: ELF aarch64 (max glibc ref 2.17), Mach-O x86_64/arm64, and PE32+ x86-64/ARM64 artifacts, with release-profile builds validated for both cargo-zigbuild and cargo-xwin. --- .github/workflows/csharp.yml | 46 ++++++------ payjoin-ffi/csharp/README.md | 8 ++ .../csharp/scripts/build_nuget_native.sh | 73 ++++++++++++++++++- 3 files changed, 103 insertions(+), 24 deletions(-) diff --git a/.github/workflows/csharp.yml b/.github/workflows/csharp.yml index 1f0cd7e22..7675120ab 100644 --- a/.github/workflows/csharp.yml +++ b/.github/workflows/csharp.yml @@ -63,8 +63,13 @@ jobs: run: dotnet test Payjoin.Tests.csproj --logger "console;verbosity=minimal" build-nuget-native: + # Every RID cross-compiles from Linux: cargo-zigbuild links the Linux targets + # against a pinned glibc floor and the macOS targets against zig's bundled + # libSystem stubs; cargo-xwin links MSVC-ABI Windows targets against the + # Microsoft CRT/SDK. The per-RID smoke jobs verify each produced asset on + # real hardware. name: "Build NuGet native asset" - runs-on: ${{ matrix.os }} + runs-on: ubuntu-latest env: RUSTUP_TOOLCHAIN: 1.85 defaults: @@ -73,39 +78,36 @@ jobs: strategy: matrix: include: - - os: ubuntu-24.04-arm - rid: linux-arm64 - - os: ubuntu-latest - rid: linux-x64 - - os: macos-latest - rid: osx-arm64 - - os: macos-15-intel - rid: osx-x64 - - os: windows-11-arm - rid: win-arm64 - - os: windows-latest - rid: win-x64 + - rid: linux-arm64 + target: aarch64-unknown-linux-gnu + - rid: linux-x64 + target: x86_64-unknown-linux-gnu + - rid: osx-arm64 + target: aarch64-apple-darwin + - rid: osx-x64 + target: x86_64-apple-darwin + - rid: win-arm64 + target: aarch64-pc-windows-msvc + - rid: win-x64 + target: x86_64-pc-windows-msvc steps: - name: Checkout uses: actions/checkout@v6 - name: Install Rust 1.85.0 uses: dtolnay/rust-toolchain@1.85.0 + with: + targets: ${{ matrix.target }} - name: Use cache uses: Swatinem/rust-cache@v2 - - name: Build release native asset (unix) - if: "!startsWith(matrix.os, 'windows')" - run: | - PAYJOIN_FFI_RID=${{ matrix.rid }} bash ./scripts/build_nuget_native.sh + - name: Install cross toolchain + run: pip install ziglang cargo-zigbuild cargo-xwin - - name: Build release native asset (windows) - if: startsWith(matrix.os, 'windows') - shell: pwsh + - name: Build release native asset run: | - $env:PAYJOIN_FFI_RID = "${{ matrix.rid }}" - ./scripts/build_nuget_native.ps1 + PAYJOIN_FFI_CROSS=1 PAYJOIN_FFI_RID=${{ matrix.rid }} bash ./scripts/build_nuget_native.sh - name: Upload NuGet native asset uses: actions/upload-artifact@v4 diff --git a/payjoin-ffi/csharp/README.md b/payjoin-ffi/csharp/README.md index 218c80120..2fa4dc15b 100644 --- a/payjoin-ffi/csharp/README.md +++ b/payjoin-ffi/csharp/README.md @@ -91,6 +91,14 @@ the C# bindings): bash ./scripts/build_nuget_native.sh ``` +Any supported RID can also be cross-compiled from a Linux host, which is how CI +builds every native asset (`pip install ziglang cargo-zigbuild cargo-xwin` and +`rustup target add` the matching triple first): + +```shell +PAYJOIN_FFI_CROSS=1 PAYJOIN_FFI_RID=osx-arm64 bash ./scripts/build_nuget_native.sh +``` + On Windows: ```powershell diff --git a/payjoin-ffi/csharp/scripts/build_nuget_native.sh b/payjoin-ffi/csharp/scripts/build_nuget_native.sh index eaab6a53c..950c66907 100755 --- a/payjoin-ffi/csharp/scripts/build_nuget_native.sh +++ b/payjoin-ffi/csharp/scripts/build_nuget_native.sh @@ -55,6 +55,50 @@ native_library_name() { esac } +# Rust target triple for cross-compiling a RID from a Linux host. Linux targets pin a +# glibc floor through zig so the produced .so loads on older distributions instead of +# inheriting the build host's glibc version. +rid_to_cross_target() { + case "$1" in + linux-x64) + echo "x86_64-unknown-linux-gnu.$PAYJOIN_FFI_GLIBC_FLOOR" + ;; + linux-arm64) + echo "aarch64-unknown-linux-gnu.$PAYJOIN_FFI_GLIBC_FLOOR" + ;; + osx-x64) + echo "x86_64-apple-darwin" + ;; + osx-arm64) + echo "aarch64-apple-darwin" + ;; + win-x64) + echo "x86_64-pc-windows-msvc" + ;; + win-arm64) + echo "aarch64-pc-windows-msvc" + ;; + *) + echo "Unsupported RID for cross build: $1" >&2 + exit 1 + ;; + esac +} + +# cargo-zigbuild links Linux (with a chosen glibc floor) and macOS targets (zig ships +# redistributable libSystem stubs); cargo-xwin links MSVC-ABI Windows targets against +# the Microsoft CRT/SDK it fetches. +rid_to_cross_tool() { + case "$1" in + linux-* | osx-*) + echo "zigbuild" + ;; + win-*) + echo "xwin build" + ;; + esac +} + SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" CSHARP_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" cd "$CSHARP_DIR" @@ -64,12 +108,37 @@ LIBNAME=$(native_library_name "$RID") export PAYJOIN_FFI_FEATURES="${PAYJOIN_FFI_FEATURES-}" export PAYJOIN_FFI_PROFILE="${PAYJOIN_FFI_PROFILE:-release}" +PAYJOIN_FFI_GLIBC_FLOOR=${PAYJOIN_FFI_GLIBC_FLOOR:-2.17} + +if [[ ${PAYJOIN_FFI_CROSS:-0} == 1 ]]; then + # Cross-compile the native library for $RID from a Linux host. Bindings are never + # generated here; the pack step generates the production bindings it packages. + TARGET=$(rid_to_cross_target "$RID") + TOOL=$(rid_to_cross_tool "$RID") + if ! command -v "cargo-${TOOL% *}" >/dev/null; then + echo "cargo-${TOOL% *} is required for cross builds: pip install cargo-zigbuild cargo-xwin" >&2 + exit 1 + fi + + GENERATOR_FEATURES="csharp${PAYJOIN_FFI_FEATURES:+,$PAYJOIN_FFI_FEATURES}" + (cd .. && cargo $TOOL --target "$TARGET" --features "$GENERATOR_FEATURES" --profile "$PAYJOIN_FFI_PROFILE" -p payjoin-ffi) -bash ./scripts/generate_bindings.sh --native-only + # Cargo writes to the triple without any zig glibc suffix. + TARGET_DIR=${TARGET%."$PAYJOIN_FFI_GLIBC_FLOOR"} + if [[ $PAYJOIN_FFI_PROFILE == "dev" ]]; then + PROFILE_DIR=debug + else + PROFILE_DIR=$PAYJOIN_FFI_PROFILE + fi + BUILT_LIB="../../target/$TARGET_DIR/$PROFILE_DIR/$LIBNAME" +else + bash ./scripts/generate_bindings.sh --native-only + BUILT_LIB="lib/$LIBNAME" +fi ARTIFACT_DIR="artifacts/runtimes/$RID/native" rm -rf "$ARTIFACT_DIR" mkdir -p "$ARTIFACT_DIR" -cp "lib/$LIBNAME" "$ARTIFACT_DIR/$LIBNAME" +cp "$BUILT_LIB" "$ARTIFACT_DIR/$LIBNAME" echo "Wrote $ARTIFACT_DIR/$LIBNAME" From 6252ef218b1a182f978fb835bc21a0899ee54d4a Mon Sep 17 00:00:00 2001 From: chavic Date: Wed, 15 Jul 2026 21:33:42 +0200 Subject: [PATCH 09/12] csharp: Pin the cross build job to the exact toolchain version RUSTUP_TOOLCHAIN=1.85 selects a different rustup toolchain entry than the 1.85.0 one the toolchain action installed the cross-target std components into, so every cross build failed with E0463 (can't find crate for core). Pin the job to 1.85.0 so the build uses the toolchain that owns the added targets. --- .github/workflows/csharp.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/csharp.yml b/.github/workflows/csharp.yml index 7675120ab..fce7d3d4c 100644 --- a/.github/workflows/csharp.yml +++ b/.github/workflows/csharp.yml @@ -71,7 +71,10 @@ jobs: name: "Build NuGet native asset" runs-on: ubuntu-latest env: - RUSTUP_TOOLCHAIN: 1.85 + # Must match the dtolnay/rust-toolchain version exactly: the cross-target + # std components are installed for that toolchain, and a partial version + # here selects a different rustup toolchain entry without them (E0463). + RUSTUP_TOOLCHAIN: 1.85.0 defaults: run: working-directory: payjoin-ffi/csharp From 40a53bd0bf480dec76af51e0e6f71a9da9745add Mon Sep 17 00:00:00 2001 From: chavic Date: Wed, 15 Jul 2026 21:44:26 +0200 Subject: [PATCH 10/12] csharp: Satisfy shellcheck in the cross build dispatch Build the cross cargo invocation as an array instead of word-splitting a two-word tool string, which shellcheck flags as SC2086 and which quoting would have broken for 'xwin build'. --- payjoin-ffi/csharp/scripts/build_nuget_native.sh | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/payjoin-ffi/csharp/scripts/build_nuget_native.sh b/payjoin-ffi/csharp/scripts/build_nuget_native.sh index 950c66907..979c9aef9 100755 --- a/payjoin-ffi/csharp/scripts/build_nuget_native.sh +++ b/payjoin-ffi/csharp/scripts/build_nuget_native.sh @@ -94,7 +94,7 @@ rid_to_cross_tool() { echo "zigbuild" ;; win-*) - echo "xwin build" + echo "xwin" ;; esac } @@ -115,13 +115,18 @@ if [[ ${PAYJOIN_FFI_CROSS:-0} == 1 ]]; then # generated here; the pack step generates the production bindings it packages. TARGET=$(rid_to_cross_target "$RID") TOOL=$(rid_to_cross_tool "$RID") - if ! command -v "cargo-${TOOL% *}" >/dev/null; then - echo "cargo-${TOOL% *} is required for cross builds: pip install cargo-zigbuild cargo-xwin" >&2 + if ! command -v "cargo-$TOOL" >/dev/null; then + echo "cargo-$TOOL is required for cross builds: pip install cargo-zigbuild cargo-xwin" >&2 exit 1 fi + CROSS_CARGO=(cargo "$TOOL") + if [[ $TOOL == xwin ]]; then + CROSS_CARGO+=(build) + fi + GENERATOR_FEATURES="csharp${PAYJOIN_FFI_FEATURES:+,$PAYJOIN_FFI_FEATURES}" - (cd .. && cargo $TOOL --target "$TARGET" --features "$GENERATOR_FEATURES" --profile "$PAYJOIN_FFI_PROFILE" -p payjoin-ffi) + (cd .. && "${CROSS_CARGO[@]}" --target "$TARGET" --features "$GENERATOR_FEATURES" --profile "$PAYJOIN_FFI_PROFILE" -p payjoin-ffi) # Cargo writes to the triple without any zig glibc suffix. TARGET_DIR=${TARGET%."$PAYJOIN_FFI_GLIBC_FLOOR"} From 64099f35a91abb0711c7dd3576f843381c56f647 Mon Sep 17 00:00:00 2001 From: chavic Date: Wed, 15 Jul 2026 22:33:09 +0200 Subject: [PATCH 11/12] csharp: Provide the MSVC tool frontends for xwin cross builds cargo-xwin compiles C dependencies with clang-cl and archives them with llvm-lib, and cc-rs looks both up by their unversioned names. The runner image ships LLVM binaries with versioned names only, so the win-arm64 build failed with 'failed to find tool llvm-lib'. Symlink the unversioned names for the Windows rows, with an apt fallback when no versioned binary exists. Also stop cancelling sibling RID builds on the first failure; each target's result is independent signal. --- .github/workflows/csharp.yml | 34 ++++++++++++++++++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/.github/workflows/csharp.yml b/.github/workflows/csharp.yml index fce7d3d4c..9d75299f3 100644 --- a/.github/workflows/csharp.yml +++ b/.github/workflows/csharp.yml @@ -79,6 +79,9 @@ jobs: run: working-directory: payjoin-ffi/csharp strategy: + # Let every RID report its own result; a failure in one target should not + # cancel the signal from the others. + fail-fast: false matrix: include: - rid: linux-arm64 @@ -106,7 +109,32 @@ jobs: uses: Swatinem/rust-cache@v2 - name: Install cross toolchain - run: pip install ziglang cargo-zigbuild cargo-xwin + # Version- and hash-pinned: these tools produce the shipped binaries. + run: pip install --require-hashes -r scripts/cross-requirements.txt + + - name: Install MSVC toolchain frontends (clang-cl, llvm-lib) + if: startsWith(matrix.rid, 'win-') + # cargo-xwin compiles C dependencies with clang-cl and archives them with + # llvm-lib. The runner image ships LLVM with versioned binary names only, + # so expose the unversioned names cc-rs looks for. + run: | + for tool in clang-cl llvm-lib; do + if command -v "$tool" >/dev/null; then + continue + fi + latest=$(ls -1 /usr/bin/"$tool"-* 2>/dev/null | sort -V | tail -n 1) + if [ -z "$latest" ]; then + sudo apt-get update -q + sudo apt-get install -y -q llvm clang + latest=$(ls -1 /usr/bin/"$tool"-* 2>/dev/null | sort -V | tail -n 1) + fi + if [ -z "$latest" ]; then + echo "$tool not found on the runner" >&2 + exit 1 + fi + sudo ln -s "$latest" /usr/local/bin/"$tool" + done + command -v clang-cl llvm-lib - name: Build release native asset run: | @@ -124,7 +152,9 @@ jobs: runs-on: ubuntu-latest needs: build-nuget-native env: - RUSTUP_TOOLCHAIN: 1.85 + # Exact version so the packaged bindings build with the toolchain the + # action installs, not an implicitly-installed latest 1.85.x. + RUSTUP_TOOLCHAIN: 1.85.0 defaults: run: working-directory: payjoin-ffi/csharp From bb5eb7fc2db077cbe057dfd360257c1c8e2a86ff Mon Sep 17 00:00:00 2001 From: chavic Date: Wed, 15 Jul 2026 22:33:09 +0200 Subject: [PATCH 12/12] csharp: Pin the cross toolchain and the pack toolchain exactly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cross tools produce the exact binaries shipped to nuget.org, but pip installed whatever versions were latest; an upstream release or a compromised PyPI package would silently change the shipped artifacts. Pin ziglang, cargo-zigbuild, cargo-xwin, and ninja (cargo-xwin's transitive dependency) by version and wheel hash, installed with --require-hashes. pack-nuget also still used RUSTUP_TOOLCHAIN=1.85, which rustup resolves to an implicitly-installed latest 1.85.x instead of the action-pinned 1.85.0 — the same mismatch fixed for the native-asset job — and that job builds the bindings that ship in the package. Pin it to 1.85.0. --- payjoin-ffi/csharp/README.md | 5 +++-- .../csharp/scripts/cross-requirements.txt | 19 +++++++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) create mode 100644 payjoin-ffi/csharp/scripts/cross-requirements.txt diff --git a/payjoin-ffi/csharp/README.md b/payjoin-ffi/csharp/README.md index 2fa4dc15b..6c6d0f98d 100644 --- a/payjoin-ffi/csharp/README.md +++ b/payjoin-ffi/csharp/README.md @@ -92,8 +92,9 @@ bash ./scripts/build_nuget_native.sh ``` Any supported RID can also be cross-compiled from a Linux host, which is how CI -builds every native asset (`pip install ziglang cargo-zigbuild cargo-xwin` and -`rustup target add` the matching triple first): +builds every native asset (`pip install -r scripts/cross-requirements.txt` for +the version- and hash-pinned toolchain CI uses, and `rustup target add` the +matching triple first): ```shell PAYJOIN_FFI_CROSS=1 PAYJOIN_FFI_RID=osx-arm64 bash ./scripts/build_nuget_native.sh diff --git a/payjoin-ffi/csharp/scripts/cross-requirements.txt b/payjoin-ffi/csharp/scripts/cross-requirements.txt new file mode 100644 index 000000000..9f3db226f --- /dev/null +++ b/payjoin-ffi/csharp/scripts/cross-requirements.txt @@ -0,0 +1,19 @@ +# Pinned cross-compilation toolchain for the CI native-asset job (Linux runners). +# These tools produce the exact binaries shipped in the NuGet package, so they +# are pinned by version and hash: a floating install would let an upstream +# release or a compromised PyPI package silently change the shipped artifacts. +# Update deliberately, never implicitly. Hashes cover the x86_64 and aarch64 +# manylinux wheels. +ziglang==0.16.0 \ + --hash=sha256:9fcda73f62b851dd72a54b710ad40a209896db14cfb13649e62191243556342b \ + --hash=sha256:e27d409812b11e0fb89ed0200cf2e55b6464d43f9461553104e4a4f9a94a1fd5 +cargo-zigbuild==0.23.0 \ + --hash=sha256:3a2e021435a5e3c8b3a70bfc490580a3af6952185aa5ffb722b005d1fbe000c9 \ + --hash=sha256:d984af93da7a230606452ef5f6df47e6a081b12dcead8a3e8e5ff024728c94f0 +cargo-xwin==0.22.0 \ + --hash=sha256:eebe464a84371c98c9a3e2d24a87b6c0fdc575146f6670f49823a366158027bf \ + --hash=sha256:8a6d3f235590a1e583286884cbdf0b2af67164713f8a81d292f3312e0fa2355e +# Transitive dependency of cargo-xwin. +ninja==1.13.0 \ + --hash=sha256:fb46acf6b93b8dd0322adc3a4945452a4e774b75b91293bafcc7b7f8e6517dfa \ + --hash=sha256:3d00c692fb717fd511abeb44b8c5d00340c36938c12d6538ba989fe764e79630