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
150 changes: 150 additions & 0 deletions .github/workflows/python.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ on:
# change this workflow's environment.
- flake.nix
- flake.lock
push:
tags:
- "payjoin-python-[0-9]*"

jobs:
build-python-and-test:
Expand All @@ -29,3 +32,150 @@ jobs:
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. The smoke jobs below verify each wheel on real hardware.
name: "Build wheel"
runs-on: ubuntu-26.04
strategy:
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")
'

verify-tag:
name: "Verify release tag"
if: startsWith(github.ref, 'refs/tags/payjoin-python-')
permissions:
contents: read
uses: ./.github/workflows/verify-tag-hygiene.yml

publish-pypi:
name: "Publish to PyPI (trusted publishing / OIDC)"
runs-on: ubuntu-26.04
needs: [build-python-and-test, build-wheel, smoke-wheel, verify-tag]
if: startsWith(github.ref, 'refs/tags/payjoin-python-')
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-<platform>.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 <file>.whl -R payjoin/rust-payjoin
uses: actions/attest-build-provenance@v4
with:
subject-path: dist/*.whl

- name: Publish to PyPI
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-
27 changes: 19 additions & 8 deletions flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 = {
Expand Down
61 changes: 61 additions & 0 deletions payjoin-ffi/python/RELEASING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# 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, 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-<version>`, where `<version>` is the
`payjoin-ffi` crate version exactly. The tag must be annotated and
signed by a maintainer key in `contrib/release/keys/`, and the tagged
commit must be on `master`; `verify-tag` refuses to publish otherwise.

```shell
git tag -s payjoin-python-0.24.0 -m 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`.
4. Verify the publication: the PyPI listing shows the new version,
`pip install payjoin==<version>` resolves on a supported platform, and
`gh attestation verify <wheel> -R payjoin/rust-payjoin` passes against
a release asset.
82 changes: 82 additions & 0 deletions payjoin-ffi/python/contrib/build-wheel.sh
Original file line number Diff line number Diff line change
@@ -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: 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
1 change: 1 addition & 0 deletions payjoin-ffi/python/contrib/test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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..."
Expand Down
9 changes: 1 addition & 8 deletions payjoin-ffi/python/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
Loading
Loading