diff --git a/.github/actions/setup-nix/action.yml b/.github/actions/setup-nix/action.yml new file mode 100644 index 000000000..bdd358d8f --- /dev/null +++ b/.github/actions/setup-nix/action.yml @@ -0,0 +1,14 @@ +name: Set up nix +description: > + Install nix with the magic cache, keeping its FlakeHub lookup disabled. + Must run after actions/checkout so the cache can key off the repository + contents. +runs: + using: composite + steps: + - name: Install nix + uses: DeterminateSystems/determinate-nix-action@main + - name: Use nix cache + uses: DeterminateSystems/magic-nix-cache-action@main + with: + use-flakehub: false diff --git a/.github/actions/verify-tag-version/action.yml b/.github/actions/verify-tag-version/action.yml new file mode 100644 index 000000000..a21e155f0 --- /dev/null +++ b/.github/actions/verify-tag-version/action.yml @@ -0,0 +1,37 @@ +name: Verify tag version +description: > + Check that the pushed tag is the expected prefix followed by exactly the + version about to be published, refusing to publish on any mismatch. +inputs: + tag-prefix: + description: Expected tag prefix, e.g. payjoin-csharp- + required: true + version: + description: Version of the artifact or package about to be published + required: true +outputs: + version: + description: The version the tag calls for + value: ${{ steps.check.outputs.version }} +runs: + using: composite + steps: + - name: Compare tag against version + id: check + shell: bash + env: + TAG: ${{ github.ref_name }} + PREFIX: ${{ inputs.tag-prefix }} + VERSION: ${{ inputs.version }} + run: | + set -euo pipefail + if [[ $TAG != "$PREFIX"* ]]; then + echo "::error::tag $TAG does not start with $PREFIX; refusing to publish" + exit 1 + fi + tag_version="${TAG#"$PREFIX"}" + if [[ $tag_version != "$VERSION" ]]; then + echo "::error::tag $TAG implies version $tag_version but the packed version is $VERSION; refusing to publish" + exit 1 + fi + echo "version=$tag_version" >>"$GITHUB_OUTPUT" diff --git a/.github/workflows/crates-release.yml b/.github/workflows/crates-release.yml index 5bea7e7b3..517e73a74 100644 --- a/.github/workflows/crates-release.yml +++ b/.github/workflows/crates-release.yml @@ -31,12 +31,8 @@ jobs: uses: actions/checkout@v6 with: fetch-depth: 0 - - name: Install nix - uses: DeterminateSystems/determinate-nix-action@main - - name: Use nix cache - uses: DeterminateSystems/magic-nix-cache-action@main - with: - use-flakehub: false + - name: Set up nix + uses: ./.github/actions/setup-nix - name: Check the bump is consistent and publishable env: BASE_SHA: ${{ github.event.pull_request.base.sha }} @@ -57,12 +53,8 @@ jobs: fetch-depth: 0 - name: Fetch master run: git fetch --no-tags origin +refs/heads/master:refs/remotes/origin/master - - name: Install nix - uses: DeterminateSystems/determinate-nix-action@main - - name: Use nix cache - uses: DeterminateSystems/magic-nix-cache-action@main - with: - use-flakehub: false + - name: Set up nix + uses: ./.github/actions/setup-nix - name: Compute tag metadata id: meta env: @@ -155,12 +147,8 @@ jobs: steps: - name: Checkout repo uses: actions/checkout@v6 - - name: Install nix - uses: DeterminateSystems/determinate-nix-action@main - - name: Use nix cache - uses: DeterminateSystems/magic-nix-cache-action@main - with: - use-flakehub: false + - name: Set up nix + uses: ./.github/actions/setup-nix - name: Download packaged crate uses: actions/download-artifact@v4 with: @@ -200,12 +188,8 @@ jobs: steps: - name: Checkout repo uses: actions/checkout@v6 - - name: Install nix - uses: DeterminateSystems/determinate-nix-action@main - - name: Use nix cache - uses: DeterminateSystems/magic-nix-cache-action@main - with: - use-flakehub: false + - name: Set up nix + uses: ./.github/actions/setup-nix - name: Download packaged crate uses: actions/download-artifact@v8 with: diff --git a/.github/workflows/csharp.yml b/.github/workflows/csharp.yml index b5476545d..e2ab03766 100644 --- a/.github/workflows/csharp.yml +++ b/.github/workflows/csharp.yml @@ -35,12 +35,8 @@ jobs: uses: actions/checkout@v6 - name: "Use cache" uses: Swatinem/rust-cache@v2 - - name: "Install nix" - uses: DeterminateSystems/determinate-nix-action@main - - name: "Use nix cache" - uses: DeterminateSystems/magic-nix-cache-action@main - with: - use-flakehub: false + - name: Set up nix + uses: ./.github/actions/setup-nix - name: "Build and test" run: nix develop .#csharp -c ./payjoin-ffi/csharp/contrib/test.sh @@ -276,8 +272,11 @@ jobs: 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 + contents: read # needed only to check out the in-repo verify-tag-version action steps: + - name: Checkout + uses: actions/checkout@v6 + - name: Download packed NuGet package uses: actions/download-artifact@v4 with: @@ -289,27 +288,28 @@ jobs: with: dotnet-version: "10.0.x" - - name: Verify tag matches packed artifact version - id: verify + - name: Locate packed artifact + id: locate 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 + # Payjoin.0.24.0-preview.1.nupkg -> 0.24.0-preview.1 + name="$(basename "${pkgs[0]}" .nupkg)" echo "nupkg=${pkgs[0]}" >> "$GITHUB_OUTPUT" - echo "version=${version}" >> "$GITHUB_OUTPUT" + echo "version=${name#Payjoin.}" >> "$GITHUB_OUTPUT" + + - name: Verify tag matches packed artifact version + id: verify + uses: ./.github/actions/verify-tag-version + with: + tag-prefix: payjoin-csharp- + version: ${{ steps.locate.outputs.version }} - name: Attest build provenance (nupkg) # Attesting the .nupkg covers every RID native lib inside it; a consumer @@ -320,7 +320,7 @@ jobs: # published unit.) uses: actions/attest-build-provenance@v4 with: - subject-path: ${{ steps.verify.outputs.nupkg }} + subject-path: ${{ steps.locate.outputs.nupkg }} - name: NuGet login (OIDC -> short-lived API key) id: login @@ -339,7 +339,7 @@ jobs: # 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 }}" \ + dotnet nuget push "${{ steps.locate.outputs.nupkg }}" \ --api-key "${{ steps.login.outputs.NUGET_API_KEY }}" \ --source https://api.nuget.org/v3/index.json \ --no-symbols \ @@ -347,44 +347,15 @@ jobs: 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) - 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//native/). 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". + uses: ./.github/workflows/release-assets.yml + with: + artifact-pattern: payjoin-csharp-nuget-package + tag-prefix: payjoin-csharp- + # Also hash each shipped native library from inside the package, so a + # consumer can verify an individual .so/.dylib/.dll (paths are + # runtimes//native/). Matches the tor/hwi pattern. + unzip-globs: runtimes/*/native/* diff --git a/.github/workflows/dart.yml b/.github/workflows/dart.yml index 317ace255..8b33e48e9 100644 --- a/.github/workflows/dart.yml +++ b/.github/workflows/dart.yml @@ -12,6 +12,14 @@ on: # change this workflow's environment. - flake.nix - flake.lock + # Publishing path. A tag push runs the build/test and archive-verification + # jobs at the tagged commit (no path filter applies to tags), then the + # tag-gated publish job below. Tag scheme: payjoin-dart-, + # e.g. payjoin-dart-0.2.1+payjoin-1.0.0-rc.8 (the full version, including + # build metadata), matching the version-first payjoin-csharp-* convention. + push: + tags: + - "payjoin-dart-[0-9]*" jobs: build-dart-and-test: @@ -25,11 +33,75 @@ jobs: uses: actions/checkout@v6 - name: "Use cache" uses: Swatinem/rust-cache@v2 - - name: "Install nix" - uses: DeterminateSystems/determinate-nix-action@main - - name: "Use nix cache" - uses: DeterminateSystems/magic-nix-cache-action@main - with: - use-flakehub: false + - name: Set up nix + uses: ./.github/actions/setup-nix - name: "Build and test" run: nix develop .#dart -c bash ./payjoin-ffi/dart/contrib/test.sh + + verify-pub-package: + # pub.dev ships source that consumers compile through hook/build.dart, + # so there is no artifact to pack or smoke test. + name: "Verify pub.dev package" + runs-on: ubuntu-26.04 + steps: + - name: Checkout + uses: actions/checkout@v6 + - name: "Use cache" + uses: Swatinem/rust-cache@v2 + - name: Set up nix + uses: ./.github/actions/setup-nix + - name: Generate production bindings and dry-run publish + run: nix develop .#dart -c ./payjoin-ffi/dart/contrib/prepare-publish.sh + + # --------------------------------------------------------------------------- + # PUBLISH PATH (tag-gated). Runs only for a payjoin-dart-* tag push, after + # build/test and the archive verification are green. The published unit is + # the pub.dev source archive, so there is no artifact to attach to a GitHub + # release afterwards. + # --------------------------------------------------------------------------- + publish-pub: + name: "Publish to pub.dev (automated publishing / OIDC)" + runs-on: ubuntu-26.04 + needs: [build-dart-and-test, verify-pub-package] + if: startsWith(github.ref, 'refs/tags/payjoin-dart-') + # Pauses for a required reviewer before anything reaches the registry; + # the pub.dev automated-publishing config requires this environment. + environment: release + permissions: + id-token: write # OIDC: pub.dev automated publishing + contents: read + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: "Use cache" + uses: Swatinem/rust-cache@v2 + - name: Set up nix + uses: ./.github/actions/setup-nix + + - name: Read pubspec version + id: pubspec + shell: bash + run: | + set -euo pipefail + version="$(sed -n 's/^version: *//p' payjoin-ffi/dart/pubspec.yaml)" + if [ -z "$version" ]; then + echo "::error::could not read version from pubspec.yaml" + exit 1 + fi + echo "version=$version" >>"$GITHUB_OUTPUT" + + - name: Verify tag matches pubspec version + uses: ./.github/actions/verify-tag-version + with: + tag-prefix: payjoin-dart- + version: ${{ steps.pubspec.outputs.version }} + + - name: Prepare the package + run: nix develop .#dart -c ./payjoin-ffi/dart/contrib/prepare-publish.sh + + - name: Publish to pub.dev + # dart pub exchanges the job's OIDC token for short-lived pub.dev + # credentials; there is no long-lived token anywhere in this + # workflow. --force skips the interactive confirmation. + run: nix develop .#dart -c bash -c 'cd payjoin-ffi/dart && dart pub publish --force' diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml index f1740156b..e99777e93 100644 --- a/.github/workflows/format.yml +++ b/.github/workflows/format.yml @@ -8,12 +8,8 @@ jobs: steps: - name: "Checkout repo" uses: actions/checkout@v6 - - name: "Install nix" - uses: DeterminateSystems/determinate-nix-action@main - - name: "Use nix cache" - uses: DeterminateSystems/magic-nix-cache-action@main - with: - use-flakehub: false + - name: Set up nix + uses: ./.github/actions/setup-nix - name: Run treefmt run: | set -eo pipefail diff --git a/.github/workflows/javascript.yml b/.github/workflows/javascript.yml index 0feafb77d..45555c5e4 100644 --- a/.github/workflows/javascript.yml +++ b/.github/workflows/javascript.yml @@ -12,6 +12,14 @@ on: # 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-javascript-, e.g. payjoin-javascript-0.1.1, matching the version-first + # payjoin-csharp-* convention. + push: + tags: + - "payjoin-javascript-[0-9]*" jobs: build-js-and-test: @@ -25,11 +33,143 @@ jobs: uses: actions/checkout@v6 - name: "Use cache" uses: Swatinem/rust-cache@v2 - - name: "Install nix" - uses: DeterminateSystems/determinate-nix-action@main - - name: "Use nix cache" - uses: DeterminateSystems/magic-nix-cache-action@main - with: - use-flakehub: false + - name: Set up nix + uses: ./.github/actions/setup-nix - name: "Build and test" run: nix develop .#javascript -c ./payjoin-ffi/javascript/contrib/test.sh + + pack-npm: + # The package ships only dist/ (wasm + compiled TypeScript), which is + # platform-independent, so one pack on Linux is the entire release build. + name: "Pack npm package" + runs-on: ubuntu-26.04 + steps: + - name: Checkout + uses: actions/checkout@v6 + - name: "Use cache" + uses: Swatinem/rust-cache@v2 + - name: Set up nix + uses: ./.github/actions/setup-nix + - name: Build and pack + run: nix develop .#javascript -c ./payjoin-ffi/javascript/contrib/pack.sh + - name: Upload npm package + uses: actions/upload-artifact@v4 + with: + name: payjoin-javascript-npm-package + path: payjoin-ffi/javascript/artifacts/*.tgz + if-no-files-found: error + + smoke-npm: + name: "Smoke test npm package" + runs-on: ${{ matrix.os }} + needs: pack-npm + strategy: + matrix: + os: [ubuntu-26.04, macos-latest] + steps: + - name: Download npm package + uses: actions/download-artifact@v4 + with: + name: payjoin-javascript-npm-package + path: pkg + - name: Install Node + uses: actions/setup-node@v4 + with: + node-version: "24" + - name: Install and exercise the packed package + shell: bash + run: | + set -euo pipefail + mkdir smoke && cd smoke + npm init -y >/dev/null + npm install ../pkg/payjoin-*.tgz + node --input-type=module -e ' + import { payjoin, uniffiInitAsync } from "payjoin"; + await uniffiInitAsync(); + payjoin.Url.parse("https://example.com"); + console.log("smoke ok"); + ' + + # --------------------------------------------------------------------------- + # PUBLISH PATH (tag-gated). Runs only for a payjoin-javascript-* tag push, + # after build/test, pack, and smoke are green. Consumes the already-built, + # already-smoke-tested tarball and never repacks. + # --------------------------------------------------------------------------- + publish-npm: + name: "Publish to npmjs.com (trusted publishing / OIDC)" + runs-on: ubuntu-26.04 + needs: [build-js-and-test, pack-npm, smoke-npm] + if: startsWith(github.ref, 'refs/tags/payjoin-javascript-') + # Pauses for a required reviewer before anything reaches the registry; + # the npmjs.com trusted-publishing policy is bound to this environment. + environment: release + permissions: + id-token: write # OIDC: npm trusted publishing and its provenance statement + attestations: write # actions/attest-build-provenance writes the attestation + contents: read # needed only to check out the in-repo verify-tag-version action + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Download npm package + uses: actions/download-artifact@v4 + with: + name: payjoin-javascript-npm-package + path: dist + + - name: Install Node + uses: actions/setup-node@v4 + with: + node-version: "24" + registry-url: https://registry.npmjs.org + + - name: Update npm + # Trusted publishing requires npm 11.5.1 or newer. + run: | + npm install -g npm@11 + npm --version + + - name: Locate packed artifact + id: locate + shell: bash + run: | + set -euo pipefail + shopt -s nullglob + pkgs=(dist/payjoin-*.tgz) + if [ "${#pkgs[@]}" -ne 1 ]; then + echo "::error::expected exactly one .tgz in dist/, found ${#pkgs[@]}: ${pkgs[*]:-none}" + exit 1 + fi + # payjoin-0.1.1.tgz -> 0.1.1 + name="$(basename "${pkgs[0]}" .tgz)" + echo "tarball=${pkgs[0]}" >> "$GITHUB_OUTPUT" + echo "version=${name#payjoin-}" >> "$GITHUB_OUTPUT" + + - name: Verify tag matches packed artifact version + uses: ./.github/actions/verify-tag-version + with: + tag-prefix: payjoin-javascript- + version: ${{ steps.locate.outputs.version }} + + - name: Attest build provenance (tarball) + # A consumer runs: gh attestation verify .tgz -R payjoin/rust-payjoin + uses: actions/attest-build-provenance@v4 + with: + subject-path: ${{ steps.locate.outputs.tarball }} + + - name: Publish to npmjs.com + # Trusted publishing exchanges the job's OIDC token for short-lived + # registry credentials and attaches npm provenance automatically; + # there is no long-lived token anywhere in this workflow. + run: npm publish "${{ steps.locate.outputs.tarball }}" + + github-release: + name: "Attach tarball + SHA256SUMS to the GitHub release" + needs: [publish-npm] + if: startsWith(github.ref, 'refs/tags/payjoin-javascript-') + permissions: + contents: write # create/update the Release for this tag and upload assets + uses: ./.github/workflows/release-assets.yml + with: + artifact-pattern: payjoin-javascript-npm-package + tag-prefix: payjoin-javascript- diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index 187ea365b..fd39f61d0 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -12,6 +12,15 @@ on: # change this workflow's environment. - flake.nix - flake.lock + # Publishing path. A tag push runs the full build/wheel/smoke graph at the + # tagged commit (no path filter applies to tags), then the tag-gated + # publish/release jobs below. Tag scheme: payjoin-python-, e.g. + # payjoin-python-0.24.0, matching the version-first payjoin-csharp-* + # convention. The version is the payjoin-ffi crate version: setup.py + # derives the wheel version from payjoin-ffi/Cargo.toml. + push: + tags: + - "payjoin-python-[0-9]*" jobs: build-python-and-test: @@ -25,11 +34,163 @@ jobs: uses: actions/checkout@v6 - name: "Use cache" uses: Swatinem/rust-cache@v2 - - name: "Install nix" - uses: DeterminateSystems/determinate-nix-action@main - - name: "Use nix cache" - uses: DeterminateSystems/magic-nix-cache-action@main - with: - use-flakehub: false + - name: Set up nix + uses: ./.github/actions/setup-nix - name: "Build and test" run: nix develop .#python --command bash ./payjoin-ffi/python/contrib/test.sh + + build-wheel: + # The wheel bundles a prebuilt native library, so each supported + # platform packs its own wheel: x86_64 on Linux (retagged manylinux by + # auditwheel) and a fat universal2 dylib for macOS. Both wheels build + # on a Linux host, like the C# native assets: a dylib linked inside + # the nix dev shell on a mac records nix store install names that + # exist on no user machine, while cargo-zigbuild links against zig's + # bundled Apple SDK stubs and records the system ones. The smoke jobs + # below verify each wheel on real hardware. + name: "Build wheel" + runs-on: ubuntu-26.04 + strategy: + # Let every platform report its own result; a failure in one target + # should not cancel the signal from the others. + fail-fast: false + matrix: + platform: [linux-x64, macos-universal2] + steps: + - name: Checkout + uses: actions/checkout@v6 + - name: "Use cache" + uses: Swatinem/rust-cache@v2 + - name: Set up nix + uses: ./.github/actions/setup-nix + - name: Build the wheel + run: nix develop .#python --command bash ./payjoin-ffi/python/contrib/build-wheel.sh + env: + PAYJOIN_WHEEL_PLATFORM: ${{ matrix.platform }} + - name: Upload wheel + uses: actions/upload-artifact@v4 + with: + name: payjoin-python-wheel-${{ matrix.platform }} + path: payjoin-ffi/python/dist/*.whl + if-no-files-found: error + + smoke-wheel: + name: "Smoke test wheel" + runs-on: ${{ matrix.os }} + needs: build-wheel + strategy: + matrix: + os: [ubuntu-26.04, macos-latest, macos-15-intel] + steps: + - name: Download wheels + uses: actions/download-artifact@v4 + with: + pattern: payjoin-python-wheel-* + merge-multiple: true + path: dist + - name: Install Python + uses: actions/setup-python@v5 + with: + # Deliberately not the version the wheel was built with: the + # wheels are tagged py3-none (the bindings load the library + # through ctypes), and installing on a different CPython proves + # the retag. + python-version: "3.12" + - name: Install and exercise the wheel + shell: bash + run: | + set -euo pipefail + shopt -s nullglob + pkgs=(dist/payjoin-*.whl) + # download-artifact succeeds even when the pattern matches nothing. + if [ "${#pkgs[@]}" -eq 0 ]; then + echo "::error::no payjoin wheels found in dist/" + exit 1 + fi + version="$(basename "${pkgs[0]}" | cut -d- -f2)" + # pip picks whichever wheel matches this runner's platform; + # dependencies come from PyPI. + python -m pip install --only-binary payjoin --find-links dist "payjoin==$version" + python -c ' + import payjoin + payjoin.Url.parse("bitcoin:12c6DSiU4Rq3P4ZxziKxzrL5LmMBrzjrJX?amount=1&pj=https://example.com") + print("smoke ok") + ' + + # --------------------------------------------------------------------------- + # PUBLISH PATH (tag-gated). Runs only for a payjoin-python-* tag push, after + # build/test, both wheel builds, and every smoke job are green. Consumes the + # already-built, already-smoke-tested wheels and never rebuilds. + # --------------------------------------------------------------------------- + publish-pypi: + name: "Publish to PyPI (trusted publishing / OIDC)" + runs-on: ubuntu-26.04 + needs: [build-python-and-test, build-wheel, smoke-wheel] + if: startsWith(github.ref, 'refs/tags/payjoin-python-') + # Pauses for a required reviewer before anything reaches the registry; + # the PyPI trusted-publishing policy is bound to this environment. + environment: release + permissions: + id-token: write # OIDC: PyPI trusted publishing and its PEP 740 attestations + attestations: write # actions/attest-build-provenance writes the attestation + contents: read # needed only to check out the in-repo verify-tag-version action + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Download wheels + uses: actions/download-artifact@v4 + with: + pattern: payjoin-python-wheel-* + merge-multiple: true + path: dist + + - name: Locate packed artifacts + id: locate + shell: bash + run: | + set -euo pipefail + shopt -s nullglob + pkgs=(dist/payjoin-*.whl) + if [ "${#pkgs[@]}" -ne 2 ]; then + echo "::error::expected exactly two wheels in dist/, found ${#pkgs[@]}: ${pkgs[*]:-none}" + exit 1 + fi + # payjoin-0.24.0-py3-none-.whl -> 0.24.0 + versions="$(for pkg in "${pkgs[@]}"; do basename "$pkg" | cut -d- -f2; done | sort -u)" + if [ "$(echo "$versions" | wc -l)" -ne 1 ]; then + echo "::error::wheels disagree on version: $versions; refusing to publish" + exit 1 + fi + echo "version=$versions" >> "$GITHUB_OUTPUT" + + - name: Verify tag matches packed artifact version + uses: ./.github/actions/verify-tag-version + with: + tag-prefix: payjoin-python- + version: ${{ steps.locate.outputs.version }} + + - name: Attest build provenance (wheels) + # A consumer runs: gh attestation verify .whl -R payjoin/rust-payjoin + uses: actions/attest-build-provenance@v4 + with: + subject-path: dist/*.whl + + - name: Publish to PyPI + # Exchanges the job's OIDC token for short-lived registry + # credentials and uploads PEP 740 attestations alongside the + # wheels; there is no long-lived token anywhere in this workflow. + uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: dist + + github-release: + name: "Attach wheels + SHA256SUMS to the GitHub release" + needs: [publish-pypi] + if: startsWith(github.ref, 'refs/tags/payjoin-python-') + permissions: + contents: write # create/update the Release for this tag and upload assets + uses: ./.github/workflows/release-assets.yml + with: + artifact-pattern: payjoin-python-wheel-* + tag-prefix: payjoin-python- diff --git a/.github/workflows/release-assets.yml b/.github/workflows/release-assets.yml new file mode 100644 index 000000000..104a635f8 --- /dev/null +++ b/.github/workflows/release-assets.yml @@ -0,0 +1,90 @@ +name: Attach release assets + +# Reusable tail of every binding publish workflow: download the packed +# artifact(s), generate SHA256SUMS, and attach everything to the GitHub +# release for the pushed tag. A maintainer's GPG detached signature over +# SHA256SUMS is added out-of-band (locally, then uploaded as +# SHA256SUMS.asc), so no signing key ever reaches a runner. + +on: + workflow_call: + inputs: + artifact-pattern: + description: Name (or glob) of the artifact(s) holding the files to attach + required: true + type: string + tag-prefix: + description: > + Tag prefix before the version, e.g. payjoin-csharp-. Used to + derive the version for pre-release detection. + required: true + type: string + unzip-globs: + description: > + Optional whitespace-separated globs extracted from each + zip-format asset and appended to SHA256SUMS, so files nested + inside a package (e.g. native libraries) can be verified + individually. + required: false + type: string + default: "" + +jobs: + github-release: + name: Create GitHub release + runs-on: ubuntu-26.04 + permissions: + contents: write # create/update the Release for this tag and upload assets + steps: + - name: Download release artifacts + uses: actions/download-artifact@v4 + with: + pattern: ${{ inputs.artifact-pattern }} + merge-multiple: true + path: dist + + - name: Generate SHA256SUMS + working-directory: dist + shell: bash + env: + UNZIP_GLOBS: ${{ inputs.unzip-globs }} + run: | + set -euo pipefail + find . -type f -printf '%P\0' | sort -z | xargs -0 sha256sum >SHA256SUMS + if [ -n "$UNZIP_GLOBS" ]; then + read -ra globs <<<"$UNZIP_GLOBS" + tmp="$(mktemp -d)" + shopt -s nullglob + for archive in *.nupkg *.whl *.zip; do + unzip -q "$archive" -d "$tmp" "${globs[@]}" + done + (cd "$tmp" && find . -type f -printf '%P\0' | sort -z | xargs -0 sha256sum) >>SHA256SUMS + fi + echo "----- SHA256SUMS -----" + cat SHA256SUMS + + - name: Derive pre-release status from the tag + id: meta + shell: bash + env: + TAG: ${{ github.ref_name }} + PREFIX: ${{ inputs.tag-prefix }} + run: | + set -euo pipefail + version="${TAG#"$PREFIX"}" + # SemVer: a hyphen in the version proper marks a pre-release; + # build metadata after a + never does. + version="${version%%+*}" + if [[ $version == *-* ]]; then + echo "prerelease=true" >>"$GITHUB_OUTPUT" + else + echo "prerelease=false" >>"$GITHUB_OUTPUT" + fi + + - name: Create / update GitHub release + uses: softprops/action-gh-release@v3 + with: + files: dist/* + fail_on_unmatched_files: true + draft: false + prerelease: ${{ steps.meta.outputs.prerelease }} diff --git a/flake.nix b/flake.nix index 7d86c760e..a24aa0953 100644 --- a/flake.nix +++ b/flake.nix @@ -397,14 +397,16 @@ "rustfmt" "llvm-tools-preview" ]; - targets = - pkgs.lib.optionals pkgs.stdenv.isDarwin [ - "aarch64-apple-darwin" - "x86_64-apple-darwin" - ] - ++ pkgs.lib.optionals pkgs.stdenv.isLinux [ - "x86_64-unknown-linux-gnu" - ]; + targets = [ + # On Darwin the native test builds lipo both arches; on Linux + # cargo-zigbuild cross-links the release wheel's universal2 + # dylib from these same targets. + "aarch64-apple-darwin" + "x86_64-apple-darwin" + ] + ++ pkgs.lib.optionals pkgs.stdenv.isLinux [ + "x86_64-unknown-linux-gnu" + ]; }; pythonDevShell = pkgs.mkShell { @@ -416,11 +418,20 @@ uv pythonRustToolchain bzip2 # needed for some machines to have access to libzip at runtime + # Provides the wheel CLI; the uv2nix venv omits build backends. + python3Packages.wheel ] ++ lib.optionals pkgs.stdenv.isLinux [ pkg-config openssl clang + # Applies the manylinux platform tag the release wheel satisfies; + # PyPI rejects raw linux_x86_64 wheels. + auditwheel + # Cross-links the macOS release wheel against zig's bundled + # Apple SDK stubs, so the dylib records system install names + # instead of nix store paths. + cargo-zigbuild ]; env = { diff --git a/payjoin-ffi/dart/CONTRIBUTING.md b/payjoin-ffi/dart/CONTRIBUTING.md index 5c4790285..b0cf93051 100644 --- a/payjoin-ffi/dart/CONTRIBUTING.md +++ b/payjoin-ffi/dart/CONTRIBUTING.md @@ -45,39 +45,64 @@ patch bump at minimum, since the same Dart API gets new behavior. ### Publishing +CI is the publish path. On every pull request touching `payjoin-ffi/**`, +the `Build and Test Dart` workflow regenerates the production bindings and +validates the archive with a publish dry run +([`contrib/prepare-publish.sh`](contrib/prepare-publish.sh)). + 1. Point the `payjoin-ffi` dependency in `native/Cargo.toml` at the commit tagged for the `payjoin` release being wrapped. Consumers build from that revision. `.cargo/config.toml` redirects it to the local workspace for development only, and `.pubignore` withholds that file from the archive. 2. Set the version in `pubspec.yaml` and describe the consumer-visible changes under a matching heading in `CHANGELOG.md`. -3. Run the tests: `bash ./contrib/test.sh`. -4. Generate the bindings to be shipped and inspect the archive. +3. Confirm every `Build and Test Dart` job is green on the release commit + in `master`. +4. Tag that commit `payjoin-dart-`, where `` is the + `pubspec.yaml` version exactly (including the `+` build metadata), and + push the tag: ```shell - bash ./scripts/generate_bindings.sh - dart pub publish --dry-run + git tag 'payjoin-dart-0.2.1+payjoin-1.0.0-rc.8' + git push upstream 'payjoin-dart-0.2.1+payjoin-1.0.0-rc.8' ``` - `.pubignore` replaces `.gitignore` for publishing, so a gitignored file is - only kept out of the archive if `.pubignore` also lists it. Two build - artifacts decide the contents here: `lib/payjoin.dart` has to be present - and current, since it is the binding surface consumers import, and - `native/Cargo.lock` has to be absent, since publishing one resolved - against the `.cargo/config.toml` path overlay would hand consumers a - lockfile pinned to a dependency graph they cannot reproduce. Delete it - before publishing if a local build left one behind. + The tag reruns the tests and the archive verification at the tagged + commit, then `publish-pub` verifies the tag matches `pubspec.yaml`, + regenerates the production bindings, and publishes through pub.dev + [automated publishing] (OIDC), so no long-lived credential is stored + anywhere. The job runs in the `release` environment: approve the paused + run before anything reaches the registry. -5. Publish. +5. Verify the [pub.dev listing](https://pub.dev/packages/payjoin) shows the + new version and its changelog. - ```shell - dart pub publish - ``` +One-time setup is account and repository configuration, not part of the +per-release flow: on the pub.dev package admin page, enable GitHub +Actions publishing with repository `payjoin/rust-payjoin` and tag +pattern `payjoin-dart-{{version}}`, requiring the GitHub Actions +environment `release`; and create the `release` GitHub Actions +environment with required reviewers. + +#### Manual fallback + +Use only if the CI publish path is unavailable. Requires uploader rights on +the pub.dev package. + +```shell +bash ./contrib/prepare-publish.sh +dart pub publish +``` -Known limitation: `scripts/generate_bindings.sh` always builds with -`_test-utils`, so the bindings it emits declare test-only APIs such as -`TestServices` and `BitcoindEnv`. Consumers build the native library without -that feature, which leaves those declarations backed by symbols that are -absent at runtime. Every release so far ships them. Giving the script a -production mode, as `payjoin-ffi/csharp` does with `PAYJOIN_FFI_FEATURES`, -remains to be done. +The script generates the bindings in production mode +(`PAYJOIN_FFI_FEATURES=`, no test-only APIs), deletes `native/Cargo.lock`, +and runs the dry-run validation. `.pubignore` replaces `.gitignore` for +publishing, so a gitignored file is only kept out of the archive if +`.pubignore` also lists it. Two build artifacts decide the contents: +`lib/payjoin.dart` has to be present and current, since it is the binding +surface consumers import, and `native/Cargo.lock` has to be absent, since +publishing one resolved against the `.cargo/config.toml` path overlay would +hand consumers a lockfile pinned to a dependency graph they cannot +reproduce. + +[automated publishing]: https://dart.dev/tools/pub/automated-publishing diff --git a/payjoin-ffi/dart/contrib/prepare-publish.sh b/payjoin-ffi/dart/contrib/prepare-publish.sh new file mode 100755 index 000000000..1fd533e18 --- /dev/null +++ b/payjoin-ffi/dart/contrib/prepare-publish.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Prepare the package for publishing to pub.dev and verify the archive with +# a dry run. The archive ships Dart source plus the native/ wrapper crate; +# consumers compile the Rust themselves via hook/build.dart, so no binaries +# are packed here. Publishing itself stays a separate step so this script +# can run anywhere, including on pull requests. + +# Build against the maintained lockfile instead of resolving the dependency +# graph fresh on every run. use_lockfile copies Cargo-recent.lock into place +# and restores the previous state when this script exits. +REPO_ROOT="$(cd "$(dirname "$0")/../../.." && pwd)" +cd "$REPO_ROOT" +source contrib/lockfile.sh +use_lockfile Cargo-recent.lock + +cd "$REPO_ROOT/payjoin-ffi/dart" + +echo "==> Generating production FFI bindings..." +PAYJOIN_FFI_FEATURES="" bash ./scripts/generate_bindings.sh + +# A Cargo.lock resolved against the .cargo/config.toml path overlay would +# hand consumers a dependency graph they cannot reproduce. +echo "==> Cleaning nested Cargo.lock..." +rm -f native/Cargo.lock + +echo "==> Verifying the publish archive..." +# The dry run exits 65 whenever validation reports anything, and one +# warning is unavoidable: the generated bindings carry analyzer warnings +# (unused imports in uniffi-dart output, and the .pubignore'd +# lib/test_utils.dart references test-only APIs that production bindings +# omit). The real publish runs with --force, which proceeds over +# warnings, so anything beyond that known finding has to fail here. +status=0 +report="$(dart pub publish --dry-run 2>&1)" || status=$? +printf '%s\n' "$report" +if [[ $status -ne 0 && $status -ne 65 ]]; then + exit "$status" +fi +unexpected="$(grep '^\* ' <<<"$report" | grep -v "^\* \`dart analyze\` found" || true)" +if [[ -n $unexpected ]]; then + echo "Unexpected validation findings; fix them before publishing:" >&2 + printf '%s\n' "$unexpected" >&2 + exit 1 +fi diff --git a/payjoin-ffi/dart/scripts/generate_bindings.sh b/payjoin-ffi/dart/scripts/generate_bindings.sh index 1eae49889..3cd4fbfb4 100755 --- a/payjoin-ffi/dart/scripts/generate_bindings.sh +++ b/payjoin-ffi/dart/scripts/generate_bindings.sh @@ -19,7 +19,20 @@ fi cd ../ echo "Generating payjoin dart..." -cargo build --features dart,_test-utils --profile dev -cargo run --features dart,_test-utils --profile dev --bin uniffi-bindgen -- --library ../target/debug/$LIBNAME --language dart --out-dir dart/lib/ +# Keep parity with other language test scripts: include _test-utils by default. +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="dart" +if [[ -n $PAYJOIN_FFI_FEATURES ]]; then + GENERATOR_FEATURES="$GENERATOR_FEATURES,$PAYJOIN_FFI_FEATURES" +fi + +cargo build --features "$GENERATOR_FEATURES" --profile "$PAYJOIN_FFI_PROFILE" +cargo run --features "$GENERATOR_FEATURES" --profile dev --bin uniffi-bindgen -- --library "../target/$TARGET_PROFILE_DIR/$LIBNAME" --language dart --out-dir dart/lib/ echo "All done!" diff --git a/payjoin-ffi/javascript/.gitignore b/payjoin-ffi/javascript/.gitignore index 2c04b1dce..5556909ef 100644 --- a/payjoin-ffi/javascript/.gitignore +++ b/payjoin-ffi/javascript/.gitignore @@ -1,6 +1,7 @@ # Build outputs dist/ node_modules/ +artifacts/ # Generated by uniffi-bindgen-react-native rust_modules/ diff --git a/payjoin-ffi/javascript/RELEASING.md b/payjoin-ffi/javascript/RELEASING.md new file mode 100644 index 000000000..be85f4a64 --- /dev/null +++ b/payjoin-ffi/javascript/RELEASING.md @@ -0,0 +1,67 @@ +# Releasing the payjoin npm package + +Maintainer documentation for publishing the `payjoin` package to +[npmjs.com](https://www.npmjs.com/package/payjoin). Consumer documentation +lives in [`README.md`](README.md). + +## Versioning + +- The package version is set in `package.json`. +- It is the package's own semantic version, independent of the + `payjoin-ffi` crate version while the JavaScript API stabilizes. +- `package.json` is the only place the version is maintained: the publish + job derives the version from the packed tarball and refuses to publish + if it does not match the pushed tag. + +## Publishing + +CI is the publish path. On every pull request touching `payjoin-ffi/**`, +the `Build and Test JavaScript` workflow builds the wasm package, packs the +tarball with [`contrib/pack.sh`](contrib/pack.sh), and smoke-installs it on +Linux and macOS. The tarball ships only `dist/` (wasm + compiled +TypeScript), which is platform-independent. + +1. Confirm every `Build and Test JavaScript` job is green on the release + commit in `master`. +2. Tag that commit `payjoin-javascript-`, where `` is the + `package.json` version exactly, and push the tag: + + ```shell + git tag payjoin-javascript-0.1.1 + git push upstream payjoin-javascript-0.1.1 + ``` + + The tag reruns the full build/pack/smoke graph at the tagged commit, + then `publish-npm` verifies the tag matches the packed tarball, attests + build provenance, and publishes through npm + [trusted publishing](https://docs.npmjs.com/trusted-publishers) (OIDC), + so no long-lived token is stored anywhere. The job runs in the `release` + environment: approve the paused run before anything reaches the + registry. + +3. `github-release` attaches the tarball 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. Verify the publication: the npmjs.com listing shows the new version + with a provenance badge, and + `gh attestation verify payjoin-.tgz -R payjoin/rust-payjoin` + passes against the release asset. + +One-time setup is account and repository configuration, not part of the +per-release flow: the npmjs.com trusted publisher for the `payjoin` +package (GitHub Actions, repository `payjoin/rust-payjoin`, workflow file +`javascript.yml`, environment `release`), and the `release` GitHub +Actions environment with required reviewers. + +## Manual fallback + +Use only if the CI publish path is unavailable. Requires npm ownership of +the `payjoin` package and an account with two-factor authentication. + +```shell +nix develop .#javascript -c ./payjoin-ffi/javascript/contrib/pack.sh +npm publish payjoin-ffi/javascript/artifacts/payjoin-.tgz +``` + +A manual publish carries no provenance statement; prefer republishing +through CI for anything consumers will install. diff --git a/payjoin-ffi/javascript/contrib/pack.sh b/payjoin-ffi/javascript/contrib/pack.sh new file mode 100755 index 000000000..014c4e103 --- /dev/null +++ b/payjoin-ffi/javascript/contrib/pack.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Build the production package and pack the npm tarball into artifacts/. +# The tarball ships only dist/ (wasm + compiled TypeScript), which is +# platform-independent, so a single pack is the entire release build. + +# Build against the maintained lockfile instead of resolving the dependency +# graph fresh on every run. use_lockfile copies Cargo-recent.lock into place +# and restores the previous state when this script exits. +REPO_ROOT="$(cd "$(dirname "$0")/../../.." && pwd)" +cd "$REPO_ROOT" +source contrib/lockfile.sh +use_lockfile Cargo-recent.lock + +cd "$REPO_ROOT/payjoin-ffi/javascript" + +echo "==> Installing JavaScript dependencies..." +npm ci + +echo "==> Generating FFI bindings..." +PAYJOIN_JS_BUILD_TEST_UTILS=0 bash ./scripts/generate_bindings.sh + +echo "==> Packing npm tarball..." +rm -rf artifacts +mkdir -p artifacts +npm pack --pack-destination artifacts diff --git a/payjoin-ffi/javascript/scripts/generate_bindings.sh b/payjoin-ffi/javascript/scripts/generate_bindings.sh index 6b4c7f309..31f601fab 100755 --- a/payjoin-ffi/javascript/scripts/generate_bindings.sh +++ b/payjoin-ffi/javascript/scripts/generate_bindings.sh @@ -32,6 +32,10 @@ if command -v rustup >/dev/null 2>&1 && fi npm run build -npm run build:test-utils + +# The test-utils addon is a dev-only native helper for the integration tests. +if [[ ${PAYJOIN_JS_BUILD_TEST_UTILS:-1} == 1 ]]; then + npm run build:test-utils +fi echo "All done!" diff --git a/payjoin-ffi/python/RELEASING.md b/payjoin-ffi/python/RELEASING.md new file mode 100644 index 000000000..c4e64d2c2 --- /dev/null +++ b/payjoin-ffi/python/RELEASING.md @@ -0,0 +1,81 @@ +# Releasing the payjoin Python package + +Maintainer documentation for publishing the `payjoin` package to +[PyPI](https://pypi.org/project/payjoin/). Consumer documentation lives in +[`README.md`](README.md). + +## Versioning + +- The package version is the `payjoin-ffi` crate version: `setup.py` reads + it from `payjoin-ffi/Cargo.toml` at build time, so a release always + requires the crate version to be correct first. +- There is no separate Python version to maintain: the publish job derives + the version from the built wheels and refuses to publish if it does not + match the pushed tag. + +## Producing the wheels + +CI is the release path. On every pull request touching `payjoin-ffi/**`, +the `Build and Test Python` workflow builds release wheels with +[`contrib/build-wheel.sh`](contrib/build-wheel.sh) (release profile, no +`_test-utils`) and smoke-installs them on every supported platform: + +- `manylinux` x86_64, tagged by auditwheel with the glibc floor the binary + actually satisfies; +- macOS `universal2` (a fat x86_64 + arm64 dylib), cross-compiled from the + Linux host with cargo-zigbuild like the C# native assets, so the dylib + links the Apple SDK stubs zig bundles and records system install names + rather than nix store paths. + +The wheels are tagged `py3-none` because the generated bindings load the +bundled library through `ctypes` and do not depend on a CPython ABI; any +CPython satisfying `requires-python` can install them. + +## Publishing + +1. Confirm every `Build and Test Python` job is green on the release + commit in `master`, including the per-platform smoke tests. +2. Tag that commit `payjoin-python-`, where `` is the + `payjoin-ffi` crate version exactly, and push the tag: + + ```shell + git tag payjoin-python-0.24.0 + git push upstream payjoin-python-0.24.0 + ``` + + The tag reruns the full build/wheel/smoke graph at the tagged commit, + then `publish-pypi` verifies the tag matches the built wheels, attests + build provenance, and uploads through PyPI + [trusted publishing](https://docs.pypi.org/trusted-publishers/) (OIDC) + with PEP 740 attestations, so no long-lived token is stored anywhere. + The job runs in the `release` environment: approve the paused run before + anything reaches the registry. + +3. `github-release` attaches the wheels 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. Verify the publication: the PyPI listing shows the new version, + `pip install payjoin==` resolves on a supported platform, and + `gh attestation verify -R payjoin/rust-payjoin` passes against + a release asset. + +One-time setup is account and repository configuration, not part of the +per-release flow: the PyPI trusted publisher for the `payjoin` project +(GitHub Actions, repository `payjoin/rust-payjoin`, workflow file +`python.yml`, environment `release`), and the `release` GitHub Actions +environment with required reviewers. + +## Manual fallback + +Use only if the CI publish path is unavailable. Requires maintainer rights +on the PyPI project and an account with two-factor authentication. Build +each platform's wheel on matching hardware, since a wheel only bundles +the native library built on that host: + +```shell +nix develop .#python -c bash ./payjoin-ffi/python/contrib/build-wheel.sh +uv publish payjoin-ffi/python/dist/*.whl +``` + +A manual upload carries no attestations; prefer republishing through CI +for anything consumers will install. diff --git a/payjoin-ffi/python/contrib/build-wheel.sh b/payjoin-ffi/python/contrib/build-wheel.sh new file mode 100755 index 000000000..4622e8398 --- /dev/null +++ b/payjoin-ffi/python/contrib/build-wheel.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Build the production wheel for one platform into dist/: release profile, +# no test utils. Select the platform with PAYJOIN_WHEEL_PLATFORM +# (linux-x64, the default, or macos-universal2). +# +# Both wheels build on a Linux host, like the C# native assets: a dylib +# linked inside the nix dev shell on a mac records nix store install names +# that exist on no user machine, while cargo-zigbuild links against zig's +# bundled Apple SDK stubs and records the system ones. + +if [[ "$(uname -s)" != Linux ]]; then + echo "error: release wheels build on a Linux host only" >&2 + exit 1 +fi + +PLATFORM=${PAYJOIN_WHEEL_PLATFORM:-linux-x64} + +# Build against the maintained lockfile instead of resolving the dependency +# graph fresh on every run. use_lockfile copies Cargo-recent.lock into place +# and restores the previous state when this script exits. +REPO_ROOT="$(cd "$(dirname "$0")/../../.." && pwd)" +cd "$REPO_ROOT" +source contrib/lockfile.sh +use_lockfile Cargo-recent.lock + +cd "$REPO_ROOT/payjoin-ffi/python" + +echo "==> Generating production FFI bindings..." +PAYJOIN_FFI_FEATURES="" PAYJOIN_FFI_PROFILE=release bash ./scripts/generate_bindings.sh + +# generate_bindings.sh stages the host's library; make sure the wheel +# ships exactly one platform's binary. +case "$PLATFORM" in + linux-x64) + rm -f src/payjoin/libpayjoin_ffi.dylib + ;; + macos-universal2) + echo "==> Cross-compiling the universal2 macOS library..." + (cd "$REPO_ROOT/payjoin-ffi" && + cargo zigbuild --profile release --target universal2-apple-darwin) + rm -f src/payjoin/libpayjoin_ffi.so + cp "$REPO_ROOT/target/universal2-apple-darwin/release/libpayjoin_ffi.dylib" \ + src/payjoin/ + ;; + *) + echo "error: unsupported PAYJOIN_WHEEL_PLATFORM: $PLATFORM" >&2 + exit 1 + ;; +esac + +echo "==> Building the wheel..." +# Drop setuptools' build/ staging dir too: it survives across runs and +# would leak the other platform's library into this wheel. +rm -rf build dist +uv build --wheel + +# The generated bindings load the bundled library through ctypes, so the +# wheel does not depend on a CPython ABI; setup.py tags it with the +# building interpreter's version only because has_ext_modules marks the +# wheel platform-specific. +echo "==> Retagging the wheel..." +wheel tags --python-tag py3 --abi-tag none --remove dist/*.whl + +if [[ $PLATFORM == linux-x64 ]]; then + # PyPI rejects the raw linux_x86_64 platform tag. auditwheel verifies + # the library's external dependencies and applies the manylinux tag the + # binary actually satisfies; nothing is grafted into the wheel since + # the library links only glibc. + auditwheel repair --wheel-dir dist dist/*-linux_x86_64.whl + rm dist/*-linux_x86_64.whl +else + # setup.py tagged the wheel with the build machine's (Linux) platform. + # The macOS floor is zig's: its bundled SDK stubs currently support + # macOS >= 13, and that is the minos both slices record. Keep this tag + # in sync with the LC_BUILD_VERSION of the built dylib if zig moves. + wheel tags --platform-tag macosx_13_0_universal2 --remove dist/*.whl +fi + +echo "==> Built wheel:" +ls -l dist diff --git a/payjoin-ffi/python/contrib/test.sh b/payjoin-ffi/python/contrib/test.sh index ac43385d0..03ebd007c 100755 --- a/payjoin-ffi/python/contrib/test.sh +++ b/payjoin-ffi/python/contrib/test.sh @@ -18,6 +18,7 @@ echo "==> Generating FFI bindings..." bash ./scripts/generate_bindings.sh echo "==> Building wheel..." +rm -rf dist uv build --wheel echo "==> Installing wheel..." diff --git a/payjoin-ffi/python/pyproject.toml b/payjoin-ffi/python/pyproject.toml index d3383ec09..8ac9a8b63 100644 --- a/payjoin-ffi/python/pyproject.toml +++ b/payjoin-ffi/python/pyproject.toml @@ -9,14 +9,7 @@ readme = "README.md" requires-python = ">=3.10" license = "MIT" dynamic = ["version"] -dependencies = [ - "build==1.3.0", - "semantic-version==2.9.0", - "setuptools==83.0.0", - "typing-extensions==4.0.1", - "wheel==0.46.3", - "httpx==0.28.1", -] +dependencies = ["httpx>=0.28.1,<1.0"] [tool.setuptools] packages = ["payjoin"] diff --git a/payjoin-ffi/python/scripts/generate_bindings.sh b/payjoin-ffi/python/scripts/generate_bindings.sh index 04253d5f2..055850c3d 100644 --- a/payjoin-ffi/python/scripts/generate_bindings.sh +++ b/payjoin-ffi/python/scripts/generate_bindings.sh @@ -26,32 +26,48 @@ ensure_target() { fi } +# Keep parity with other language test scripts: include _test-utils by default. +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 +FEATURE_ARGS=() +if [[ -n $PAYJOIN_FFI_FEATURES ]]; then + FEATURE_ARGS=(--features "$PAYJOIN_FFI_FEATURES") +fi + cd ../ -# This is a test script the actual release should not include the test utils feature -cargo build --features _test-utils --profile dev -cargo run --features _test-utils --profile dev --bin uniffi-bindgen generate --library ../target/debug/$LIBNAME --language python --out-dir python/src/payjoin/ +cargo build "${FEATURE_ARGS[@]}" --profile "$PAYJOIN_FFI_PROFILE" +cargo run "${FEATURE_ARGS[@]}" --profile dev --bin uniffi-bindgen generate --library "../target/$TARGET_PROFILE_DIR/$LIBNAME" --language python --out-dir python/src/payjoin/ if [[ $OS == "Darwin" ]]; then echo "Generating native binaries..." ensure_target aarch64-apple-darwin x86_64-apple-darwin - # This is a test script the actual release should not include the test utils feature - cargo build --profile dev --target aarch64-apple-darwin --features _test-utils & - cargo build --profile dev --target x86_64-apple-darwin --features _test-utils & - wait + cargo build --profile "$PAYJOIN_FFI_PROFILE" --target aarch64-apple-darwin "${FEATURE_ARGS[@]}" & + aarch64_pid=$! + cargo build --profile "$PAYJOIN_FFI_PROFILE" --target x86_64-apple-darwin "${FEATURE_ARGS[@]}" & + x86_64_pid=$! + # A bare `wait` always returns 0; wait on each build so a failure + # cannot slip through to lipo, which would happily reuse a stale + # library from an earlier build. + wait "$aarch64_pid" + wait "$x86_64_pid" echo "Building macos fat library" lipo -create -output python/src/payjoin/$LIBNAME \ - ../target/aarch64-apple-darwin/debug/$LIBNAME \ - ../target/x86_64-apple-darwin/debug/$LIBNAME + "../target/aarch64-apple-darwin/$TARGET_PROFILE_DIR/$LIBNAME" \ + "../target/x86_64-apple-darwin/$TARGET_PROFILE_DIR/$LIBNAME" else echo "Generating native binaries..." ensure_target x86_64-unknown-linux-gnu - # This is a test script the actual release should not include the test utils feature - cargo build --profile dev --target x86_64-unknown-linux-gnu --features _test-utils + cargo build --profile "$PAYJOIN_FFI_PROFILE" --target x86_64-unknown-linux-gnu "${FEATURE_ARGS[@]}" echo "Copying payjoin_ffi binary" - cp ../target/x86_64-unknown-linux-gnu/debug/$LIBNAME python/src/payjoin/$LIBNAME + cp "../target/x86_64-unknown-linux-gnu/$TARGET_PROFILE_DIR/$LIBNAME" python/src/payjoin/$LIBNAME fi echo "All done!" diff --git a/payjoin-ffi/python/uv.lock b/payjoin-ffi/python/uv.lock index 89888e3ee..591efc5b4 100644 --- a/payjoin-ffi/python/uv.lock +++ b/payjoin-ffi/python/uv.lock @@ -16,22 +16,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/85/4f/d010eca6914703d8e6be222165d02c3e708ed909cdb2b7af3743667f302e/anyio-4.1.0-py3-none-any.whl", hash = "sha256:56a415fbc462291813a94528a779597226619c8e78af7de0507333f700011e5f", size = 83924, upload-time = "2023-11-22T23:23:52.595Z" }, ] -[[package]] -name = "build" -version = "1.3.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "os_name == 'nt'" }, - { name = "importlib-metadata", marker = "python_full_version < '3.10.2'" }, - { name = "packaging" }, - { name = "pyproject-hooks" }, - { name = "tomli", marker = "python_full_version < '3.11'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/25/1c/23e33405a7c9eac261dff640926b8b5adaed6a6eb3e1767d441ed611d0c0/build-1.3.0.tar.gz", hash = "sha256:698edd0ea270bde950f53aed21f3a0135672206f3911e0176261a31e0e07b397", size = 48544, upload-time = "2025-08-01T21:27:09.268Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/8c/2b30c12155ad8de0cf641d76a8b396a16d2c36bc6d50b621a62b7c4567c1/build-1.3.0-py3-none-any.whl", hash = "sha256:7145f0b5061ba90a1500d60bd1b13ca0a8a4cebdd0cc16ed8adf1c0e739f43b4", size = 23382, upload-time = "2025-08-01T21:27:07.844Z" }, -] - [[package]] name = "certifi" version = "2025.8.3" @@ -41,15 +25,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e5/48/1549795ba7742c948d2ad169c1c8cdbae65bc450d6cd753d124b17c8cd32/certifi-2025.8.3-py3-none-any.whl", hash = "sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5", size = 161216, upload-time = "2025-08-03T03:07:45.777Z" }, ] -[[package]] -name = "colorama" -version = "0.4.6" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, -] - [[package]] name = "exceptiongroup" version = "1.2.2" @@ -105,37 +80,11 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" }, ] -[[package]] -name = "importlib-metadata" -version = "8.7.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "zipp" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/76/66/650a33bd90f786193e4de4b3ad86ea60b53c89b669a5c7be931fac31cdb0/importlib_metadata-8.7.0.tar.gz", hash = "sha256:d13b81ad223b890aa16c5471f2ac3056cf76c5f10f82d6f9292f0b415f389000", size = 56641, upload-time = "2025-04-27T15:29:01.736Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/20/b0/36bd937216ec521246249be3bf9855081de4c5e06a0c9b4219dbeda50373/importlib_metadata-8.7.0-py3-none-any.whl", hash = "sha256:e5dd1551894c77868a30651cef00984d50e1002d06942a7101d34870c5f02afd", size = 27656, upload-time = "2025-04-27T15:29:00.214Z" }, -] - -[[package]] -name = "packaging" -version = "25.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, -] - [[package]] name = "payjoin" source = { editable = "." } dependencies = [ - { name = "build" }, { name = "httpx" }, - { name = "semantic-version" }, - { name = "setuptools" }, - { name = "typing-extensions" }, - { name = "wheel" }, ] [package.dev-dependencies] @@ -145,14 +94,7 @@ dev = [ ] [package.metadata] -requires-dist = [ - { name = "build", specifier = "==1.3.0" }, - { name = "httpx", specifier = "==0.28.1" }, - { name = "semantic-version", specifier = "==2.9.0" }, - { name = "setuptools", specifier = "==83.0.0" }, - { name = "typing-extensions", specifier = "==4.0.1" }, - { name = "wheel", specifier = "==0.46.3" }, -] +requires-dist = [{ name = "httpx", specifier = ">=0.28.1,<1.0" }] [package.metadata.requires-dev] dev = [ @@ -169,33 +111,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/40/4b/2028861e724d3bd36227adfa20d3fd24c3fc6d52032f4a93c133be5d17ce/platformdirs-4.4.0-py3-none-any.whl", hash = "sha256:abd01743f24e5287cd7a5db3752faf1a2d65353f38ec26d98e25a6db65958c85", size = 18654, upload-time = "2025-08-26T14:32:02.735Z" }, ] -[[package]] -name = "pyproject-hooks" -version = "1.2.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e7/82/28175b2414effca1cdac8dc99f76d660e7a4fb0ceefa4b4ab8f5f6742925/pyproject_hooks-1.2.0.tar.gz", hash = "sha256:1e859bd5c40fae9448642dd871adf459e5e2084186e8d2c2a79a824c970da1f8", size = 19228, upload-time = "2024-09-29T09:24:13.293Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl", hash = "sha256:9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913", size = 10216, upload-time = "2024-09-29T09:24:11.978Z" }, -] - -[[package]] -name = "semantic-version" -version = "2.9.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cb/56/4aa487b46d09646eb1863faa7026551d8309ece2281794bf13b20f28ab94/semantic_version-2.9.0.tar.gz", hash = "sha256:abf54873553e5e07a6fd4d5f653b781f5ae41297a493666b59dcf214006a12b2", size = 51123, upload-time = "2022-02-06T18:45:20.132Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/64/ac/df31047966c4d0293e7bd16276ebc9f6654de36ad8e19061a09369380c0a/semantic_version-2.9.0-py2.py3-none-any.whl", hash = "sha256:db2504ab37902dd2c9876ece53567aa43a5b2a417fbe188097b2048fff46da3d", size = 15357, upload-time = "2022-02-06T18:45:17.996Z" }, -] - -[[package]] -name = "setuptools" -version = "83.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/34/26/f5d29e25ffdb535afef2d35cdb55b325298f96debd670da4c325e08d70f4/setuptools-83.0.0.tar.gz", hash = "sha256:025bccbbf0fa05b6192bc64ae1e7b16e001fd6d6d4d5de03c97b1c1ade523bef", size = 1154254, upload-time = "2026-07-04T15:31:22.699Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5d/40/e1e72872c6354b306daef1703549e8e83b4d43cfea356311bf722a043752/setuptools-83.0.0-py3-none-any.whl", hash = "sha256:29b23c360f22f414dc7336bb39178cc7bcbf6021ed2733cde173f09dba19abb3", size = 1008090, upload-time = "2026-07-04T15:31:20.885Z" }, -] - [[package]] name = "sniffio" version = "1.3.1" @@ -253,27 +168,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257, upload-time = "2024-11-27T22:38:35.385Z" }, ] -[[package]] -name = "typing-extensions" -version = "4.0.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0d/4a/60ba3706797b878016f16edc5fbaf1e222109e38d0fa4d7d9312cb53f8dd/typing_extensions-4.0.1.tar.gz", hash = "sha256:4ca091dea149f945ec56afb48dae714f21e8692ef22a395223bcd328961b6a0e", size = 22706, upload-time = "2021-12-01T01:45:39.576Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/05/e4/baf0031e39cf545f0c9edd5b1a2ea12609b7fcba2d58e118b11753d68cf0/typing_extensions-4.0.1-py3-none-any.whl", hash = "sha256:7f001e5ac290a0c0401508864c7ec868be4e701886d5b573a9528ed3973d9d3b", size = 22816, upload-time = "2021-12-01T01:45:37.772Z" }, -] - -[[package]] -name = "wheel" -version = "0.46.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "packaging" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/89/24/a2eb353a6edac9a0303977c4cb048134959dd2a51b48a269dfc9dde00c8a/wheel-0.46.3.tar.gz", hash = "sha256:e3e79874b07d776c40bd6033f8ddf76a7dad46a7b8aa1b2787a83083519a1803", size = 60605, upload-time = "2026-01-22T12:39:49.136Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/87/22/b76d483683216dde3d67cba61fb2444be8d5be289bf628c13fc0fd90e5f9/wheel-0.46.3-py3-none-any.whl", hash = "sha256:4b399d56c9d9338230118d705d9737a2a468ccca63d5e813e2a4fc7815d8bc4d", size = 30557, upload-time = "2026-01-22T12:39:48.099Z" }, -] - [[package]] name = "yapf" version = "0.43.0" @@ -286,12 +180,3 @@ sdist = { url = "https://files.pythonhosted.org/packages/23/97/b6f296d1e9cc1ec25 wheels = [ { url = "https://files.pythonhosted.org/packages/37/81/6acd6601f61e31cfb8729d3da6d5df966f80f374b78eff83760714487338/yapf-0.43.0-py3-none-any.whl", hash = "sha256:224faffbc39c428cb095818cf6ef5511fdab6f7430a10783fdfb292ccf2852ca", size = 256158, upload-time = "2024-11-14T00:11:39.37Z" }, ] - -[[package]] -name = "zipp" -version = "3.23.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547, upload-time = "2025-06-08T17:06:39.4Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" }, -]