Skip to content

Prepare release-ready C# NuGet package workflow#1707

Open
chavic wants to merge 12 commits into
payjoin:masterfrom
chavic:chavic/csharp-nuget-release-readiness-1448
Open

Prepare release-ready C# NuGet package workflow#1707
chavic wants to merge 12 commits into
payjoin:masterfrom
chavic:chavic/csharp-nuget-release-readiness-1448

Conversation

@chavic

@chavic chavic commented Jul 2, 2026

Copy link
Copy Markdown
Collaborator

Replaces the draft nuspec packaging with a packable SDK-style project that ships a compiled managed Payjoin.dll plus release-profile native libraries under runtimes/{rid}/native/ for all six supported RIDs, linux-arm64, linux-x64, osx-arm64, osx-x64, win-arm64, win-x64 and adds CI that builds each native asset, packs the .nupkg, and installs it into a clean console app with a smoke test on every RID. RELEASING.md documents versioning and the manual publish path with a release-readiness checklist. Version 0.24.0-preview.1 tracks payjoin-ffi 0.24.0 and is an intentional package-model break from the 0.0.1 placeholder. musl/Alpine is the one platform left out — it needs a musl cross-toolchain rather than just a matrix entry.

Verified locally on linux-x64 end-to-end (release native build → pack → clean-app install from a local feed → FFI smoke run, 27/27 tests); the remaining RIDs are exercised by this PR's CI.

Closes #1448

Disclosure: co-authored with Claude Code

chavic added 2 commits July 2, 2026 22:41
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.
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.
@coveralls

coveralls commented Jul 2, 2026

Copy link
Copy Markdown
Collaborator

Coverage Report for CI Build 29448737091

Warning

Build has drifted: This PR's base is out of sync with its target branch, so coverage data may include unrelated changes.
Quick fix: rebase this PR. Learn more →

Coverage increased (+0.2%) to 86.187%

Details

  • Coverage increased (+0.2%) from the base build.
  • Patch coverage: No coverable lines changed in this PR.
  • 574 coverage regressions across 20 files.

Uncovered Changes

No uncovered changes found.

Coverage Regressions

574 previously-covered lines in 20 files lost coverage.

Top 10 Files by Coverage Loss Lines Losing Coverage Coverage
payjoin-cli/src/app/v2/mod.rs 224 57.98%
payjoin/src/core/receive/v2/mod.rs 87 92.18%
payjoin/src/core/receive/error.rs 38 46.45%
payjoin-cli/src/app/config.rs 35 80.66%
payjoin/src/core/ohttp.rs 33 76.82%
payjoin/src/core/persist.rs 30 97.19%
payjoin/src/core/receive/v2/error.rs 24 31.82%
payjoin/src/core/send/v2/mod.rs 22 88.72%
payjoin-cli/src/db/v2.rs 19 82.9%
payjoin/src/core/hpke.rs 17 93.5%

Coverage Stats

Coverage Status
Relevant Lines: 15963
Covered Lines: 13758
Line Coverage: 86.19%
Coverage Strength: 345.48 hits per line

💛 - Coveralls

@chavic chavic marked this pull request as ready for review July 3, 2026 15:52
@chavic chavic requested a review from DanGould July 3, 2026 15:52
@DanGould

Copy link
Copy Markdown
Member

I'm going to point you to @spacebear21 for the release pipelining here since he's done the other languages and (it seems) has the most information to align this method with those.

@DanGould DanGould requested review from spacebear21 and removed request for DanGould July 14, 2026 08:58

@spacebear21 spacebear21 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

concept ACK!

I think it would be worth investigating whether we could use a cross-compiler such as https://github.com/cross-rs/cross to simplify compiling for multiple targets across many github runners, instead it could all be done from a single runner for an arbitrary list of targets.

I have not tested this, nor have I reviewed the C#/windows specifics since I'm not familiary with that syntax at all. @ValeraFinebits could you sanity check those?

Comment on lines +40 to +41
[publish guide], [package authoring best practices], and
[native library packaging] documentation.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

looks like some links are missing here

<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<IsPackable>true</IsPackable>
<PackageId>Payjoin</PackageId>
<Version>0.24.0-preview.1</Version>

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The latest version on master is 1.0.0-rc.4. FWIW for the npm and pub.dev releases I am sticking with 0.1.x versioning while we're stabilizing the publishing process.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay, will match those here too

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not entirely clear on the purpose and name of this file, and how it compares to contrib/test.sh?

Comment on lines +40 to +57
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
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These helpers are used in a few files now, it would be good to consolidate them and export them from a shared utilities script to prevent future divergences.

run: dotnet test --logger "console;verbosity=minimal"
run: dotnet test Payjoin.Tests.csproj --logger "console;verbosity=minimal"

build-nuget-native:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This, pack-nuget, and maybe smoke-nuget seem tightly coupled and I don't see a clear advantage of having them be independent jobs. Could they be consolidated such that:

  • The transient artifacts for each architecture aren't kept around. The only useful artifact is the final payjoin-csharp-nuget-package .nupkg file.
  • The os matrix is shared between all relevant steps, to prevent inconsistent matrices between steps on future updates.
    • Currently pack-nuget declares the matrix strategy but then uses a hardcoded loop for rid in linux-arm64 linux-x64 osx-arm64 osx-x64 win-arm64 win-x64; do that seems prone to error.

I also wonder if we could simplify the logic and not rely so much on github runners by using a cross-compiler like https://github.com/cross-rs/cross

@ValeraFinebits ValeraFinebits left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reviewed the C# and Windows-specific changes. I found no blocking issues in the CI release path.

I could not complete local packaging validation on Windows, however. After running build_nuget_native.ps1, src/payjoin.cs still contained BitcoindEnv, and dotnet pack failed the production-binding guard. This suggests that the PowerShell path is not reliably preserving the intended empty PAYJOIN_FFI_FEATURES value.

This may not block the current CI release path, because pack-nuget regenerates production bindings on Ubuntu, but it does break the documented local Windows validation flow and should be fixed or documented before publishing.

A few additional non-blocking improvements to consider, either before merge or in a follow-up:

  1. The native-asset scripts generate src/payjoin.cs in every RID build job, but pack-nuget generates the production bindings again and that is the copy ultimately packaged. Consider separating native-library builds from binding generation, or otherwise avoiding generation in the per-RID jobs when its output is not consumed there.

  2. NoPackageAnalysis=true disables package analysis globally. Could we identify the specific package-analysis warnings it avoids and suppress or address only those, so future packaging regressions remain visible?

  3. Version 0.24.0-preview.1 is duplicated in Payjoin.csproj, the workflow smoke-test invocation, and documentation. It would be safer for the workflow/scripts to derive the version from Payjoin.csproj (or from the produced .nupkg) rather than maintaining a second executable copy manually. Documentation examples can remain descriptive.

@chavic chavic self-assigned this Jul 15, 2026
chavic added 6 commits July 15, 2026 14:48
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.
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.
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.
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.
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.
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.
@chavic

chavic commented Jul 15, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks both, all four points addressed, and the cross suggestion turned into a working implementation locally.

@ValeraFinebits

  • Windows validation bug: root cause is that Windows deletes an environment variable assigned "", so the ps1 scripts' "empty means production" convention read back as unset and fell through to _test-utils. Production intent is now an explicit switch: generate_bindings.ps1 -ProductionBindings (d242b40). The README also never documented a Windows pack sequence (the bash flow's production-bindings step had no counterpart), which is fixed too (b4559ed). Could you re-run the documented Windows flow (build_nuget_native.ps1, then generate_bindings.ps1 -ProductionBindings + dotnet pack)? I can't validate on Windows locally.
  • Double generation: build_nuget_native is now native-only, the per-RID jobs skip uniffi-bindgen entirely; only the pack and test jobs generate bindings (1992835).
  • NoPackageAnalysis: packing with analysis enabled produces exactly one warning, NU5131 (ref/net10.0 assembly without a nuspec references group, which csproj-based pack can't emit, the intentional ref/runtimes layout). Suppression is now scoped to NU5131 with a comment; everything else stays visible (0db5be8).
  • Version duplication: the smoke script accepts auto and derives the version from the packed artifact; CI uses it, so Payjoin.csproj is the single place the version lives (d6bfaa2).

@spacebear21, implemented, with a bit of a hack: cross alone can't cover the matrix (no Apple-darwin images for licensing reasons, and Windows is gnu-ABI only), so this uses the maturin-ecosystem recipe instead: cargo-zigbuild for the Linux and macOS targets (zig ships redistributable libSystem stubs, so darwin links from Linux with no Apple SDK) and cargo-xwin for MSVC-ABI Windows DLLs including win-arm64 (ad25faf).

chavic added 4 commits July 15, 2026 21:33
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.
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'.
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.
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Track release readiness for the real Payjoin C# NuGet package

5 participants