Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 141 additions & 0 deletions .github/workflows/csharp.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,16 @@ on:
# the flake change this workflow's environment.
- flake.nix
- flake.lock
# Publishing path. A tag push runs the full build/pack/smoke graph at the
# tagged commit (no path filter applies to tags), then the tag-gated
# publish/release jobs below. Tag scheme: payjoin-csharp-<csproj Version>,
# e.g. payjoin-csharp-0.24.0-preview.1. The [0-9] anchor keeps this glob
# collision-free against the repo's existing payjoin-* and payjoin-cli-*
# tags (which a payjoin-csharp-* prefix cannot match anyway) while dropping
# the `v` so the scheme matches the version-first convention already in use.
push:
Comment thread
chavic marked this conversation as resolved.
tags:
- "payjoin-csharp-[0-9]*"

jobs:
build-csharp-and-test-unix:
Expand Down Expand Up @@ -245,3 +255,134 @@ jobs:
# '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 }}

# ---------------------------------------------------------------------------
# PUBLISH PATH (tag-gated). Runs only for a payjoin-csharp-* tag push, after
# pack + every per-RID smoke job are green. Consumes the already-built,
# already-smoke-tested `payjoin-csharp-nuget-package` artifact — never repacks.
# ---------------------------------------------------------------------------
publish-nuget:

@chavic chavic Aug 4, 2026

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.

pack-nuget makes the bytes to be published, but It has no tag gate and no environment gate. The verify step checks the file name, not the content. So a bad change in pack-nuget reaches nuget.org with a valid attestation.

TanStack showed this. An attestation proves which pipeline built a package. It does not prove that the pipeline was correct.

Those jobs pin actions by tag, like the rest of the repo, so this is repo-wide and not a fault of this PR. I can open a separate issue for SHA pins on the publish path.

name: "Publish to nuget.org (trusted publishing / OIDC)"
runs-on: ubuntu-latest
needs: [pack-nuget, smoke-nuget]
if: startsWith(github.ref, 'refs/tags/payjoin-csharp-')
# Optional but recommended: a GitHub Actions environment lets you bind the
# nuget.org policy to `nuget-release` AND add required reviewers, turning
# publish into a manual-approval gate. Create the environment first, or
# remove this line to publish without an approval gate. See README.
environment: nuget-release

@chavic chavic Aug 4, 2026

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.

An attacker can read an OIDC token from runner memory. TanStack used that method. This environment is the last manual control before a package goes to nuget.org. "Optional but recommended" may be too weak for that.

permissions:
id-token: write # OIDC: used by BOTH NuGet/login and attest-build-provenance
attestations: write # actions/attest-build-provenance writes the attestation
contents: read # checkout of the (already-packed) repo is not needed; read is the floor
steps:
- name: Download packed NuGet package
uses: actions/download-artifact@v4
with:
name: payjoin-csharp-nuget-package
path: dist

- name: Install .NET 10 SDK
uses: actions/setup-dotnet@v4
with:
dotnet-version: "10.0.x"

- name: Verify tag matches packed artifact version
id: verify
shell: bash
run: |
set -euo pipefail
# payjoin-csharp-0.24.0-preview.1 -> 0.24.0-preview.1
version="${GITHUB_REF_NAME#payjoin-csharp-}"
shopt -s nullglob
pkgs=(dist/*.nupkg)
if [ "${#pkgs[@]}" -ne 1 ]; then
echo "::error::expected exactly one .nupkg in dist/, found ${#pkgs[@]}: ${pkgs[*]:-none}"
exit 1
fi
expected="Payjoin.${version}.nupkg"
actual="$(basename "${pkgs[0]}")"
if [ "${actual}" != "${expected}" ]; then
echo "::error::tag ${GITHUB_REF_NAME} implies ${expected} but packed artifact is ${actual}; refusing to publish"
exit 1
fi
echo "nupkg=${pkgs[0]}" >> "$GITHUB_OUTPUT"
echo "version=${version}" >> "$GITHUB_OUTPUT"

- name: Attest build provenance (nupkg)
# Attesting the .nupkg covers every RID native lib inside it; a consumer
# runs: gh attestation verify <file>.nupkg -R payjoin/rust-payjoin
# (To attest each native .so/.dylib/.dll as an independent subject as
# well, add a second attest step with subject-checksums pointing at the
# SHA256SUMS produced in github-release. Not required — the nupkg is the
# published unit.)
uses: actions/attest-build-provenance@v4
with:
subject-path: ${{ steps.verify.outputs.nupkg }}

- name: NuGet login (OIDC -> short-lived API key)
id: login
uses: NuGet/login@v1
with:
# nuget.org username (profile name), NOT an email. Individual member of
# the org that owns the Trusted Publishing policy. Kept in a secret only
# to avoid leaking the account name in logs — it is NOT a credential;
# there is no long-lived API key anywhere in this workflow.
user: ${{ secrets.NUGET_USER }}

- name: Push to nuget.org
run: |
# --skip-duplicate keeps re-runs idempotent: if a prior run published
# this version but a later job (github-release) failed, re-triggering
# the tag would otherwise 409 on the already-published version and
# redden the whole run. With it, an already-present version is a
# no-op success and the run converges.
dotnet nuget push "${{ steps.verify.outputs.nupkg }}" \
--api-key "${{ steps.login.outputs.NUGET_API_KEY }}" \
--source https://api.nuget.org/v3/index.json \
--no-symbols \
--skip-duplicate

github-release:
name: "Attach nupkg + SHA256SUMS to the GitHub release"
runs-on: ubuntu-latest
needs: [publish-nuget]
if: startsWith(github.ref, 'refs/tags/payjoin-csharp-')
permissions:
contents: write # create/update the Release for this tag and upload assets
steps:
- name: Download packed NuGet package
uses: actions/download-artifact@v4
with:
name: payjoin-csharp-nuget-package
path: dist

- name: Generate SHA256SUMS (nupkg + each native lib)

@chavic chavic Aug 4, 2026

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 publish job checks that dist/ holds exactly one nupkg. This job does not. If dist/ holds two packages, unzip -q *.nupkg treats the second as a member pattern of the first, and the native hashes then cover one package only. should add the same check here, or pass the file name from the publish job.

working-directory: dist
shell: bash
run: |
set -euo pipefail
# Hash the package itself.
sha256sum *.nupkg > SHA256SUMS
# Also hash each shipped native library extracted from inside the
# package, so a consumer can verify an individual .so/.dylib/.dll
# (paths are runtimes/<rid>/native/<lib>). Matches the tor/hwi pattern.
tmp="$(mktemp -d)"
unzip -q *.nupkg -d "$tmp" 'runtimes/*/native/*'
( cd "$tmp" && find runtimes -type f -print0 | sort -z | xargs -0 sha256sum ) >> SHA256SUMS
echo "----- SHA256SUMS -----"
cat SHA256SUMS

- name: Create / update GitHub release
uses: softprops/action-gh-release@v3
with:
files: |
dist/*.nupkg
dist/SHA256SUMS
fail_on_unmatched_files: true
draft: false
# Mark preview / rc tags as pre-releases on the Releases page.
prerelease: ${{ contains(github.ref_name, '-preview') || contains(github.ref_name, '-rc') }}
# Dan's GPG detached signature over SHA256SUMS is added out-of-band
# (locally, then uploaded as SHA256SUMS.asc) — his private key must not
# live on a runner. See README "GPG signature".

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 two comments say "See README". That text is in RELEASING.md.

58 changes: 52 additions & 6 deletions payjoin-ffi/csharp/RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,21 +96,65 @@ Review before every publish to nuget.org. Grounded in the NuGet

## Publishing

CI is the publish path. A tag push builds, packs, smoke-tests, and pushes the
package to nuget.org via [trusted publishing] (OIDC) — no long-lived API key
is ever stored. The workflow is
[`.github/workflows/csharp.yml`](../../.github/workflows/csharp.yml) (jobs
`publish-nuget` and `github-release`).

1. Work through the release readiness checklist above on the release commit in
`master`; confirm every `Build and Test CSharp` job is green.
2. Tag that commit `payjoin-csharp-<version>`, where `<version>` is the
`Payjoin.csproj` `<Version>` exactly, and push the tag:

```shell
git tag payjoin-csharp-0.24.0-preview.1
git push upstream payjoin-csharp-0.24.0-preview.1
```

The tag reruns the full build/pack/smoke graph at the tagged commit, then
`publish-nuget` verifies the tag matches the packed
`Payjoin.<version>.nupkg`, attests build provenance, exchanges the GitHub
OIDC token for a short-lived nuget.org key via [`NuGet/login`], and pushes.
The job runs in the `nuget-release` environment: if it has required
reviewers, approve the paused run before anything reaches nuget.org.

3. `github-release` attaches the `.nupkg` and a generated `SHA256SUMS` to the
tag's GitHub release. Optionally sign `SHA256SUMS` locally and upload
`SHA256SUMS.asc` — never place a GPG key on a runner.
4. Complete the post-publish verification section of the checklist, and verify
the attestation:
`gh attestation verify Payjoin.<version>.nupkg -R payjoin/rust-payjoin`.

One-time setup — the nuget.org Trusted Publishing policy (bound to
`payjoin/rust-payjoin`, workflow file `csharp.yml`, environment
`nuget-release`), the `nuget-release` GitHub Actions environment with required
reviewers, and the `NUGET_USER` secret (the publishing member's nuget.org
profile name) — is a one-time account and repository configuration, not part
of the per-release flow.

## Manual fallback

Use only if the CI publish path is unavailable. Requires a maintainer with
nuget.org ownership of the `Payjoin` package ID.

1. Work through the release readiness checklist above.
2. Push the CI-built package:
2. Download the CI-built `payjoin-csharp-nuget-package` artifact from the
workflow run on the release commit (do not pack from a development machine —
a local pack only contains the native assets present on that host), then
push it:

```shell
dotnet nuget push Payjoin.<version>.nupkg \
--source https://api.nuget.org/v3/index.json \
--api-key <nuget.org API key>
```

3. Complete the post-publish verification section of the checklist.
Prefer a scoped, push-only, short-expiry key per [scoped API keys], or the
`NUGET_API_KEY` environment variable (.NET SDK 10.0.300+) so the key never
appears in shell history.

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.
3. Complete the post-publish verification section of the checklist.

[SemVer]: https://semver.org/
[package versioning]: https://learn.microsoft.com/en-us/nuget/concepts/package-versioning
Expand All @@ -119,3 +163,5 @@ long-lived API keys.
[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
[trusted publishing]: https://learn.microsoft.com/en-us/nuget/nuget-org/trusted-publishing
[`NuGet/login`]: https://github.com/NuGet/login
Loading