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
208 changes: 208 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
name: Release

on:
workflow_dispatch:
inputs:
dry_run:
description: Verify only; do not publish packages, tags, or releases
type: boolean
required: true
default: true
pull_request:
paths:
- .github/workflows/release.yml
- scripts/release.py
- scripts/verify_distribution.py
- tests/test_release.py
- pyproject.toml
- uv.lock

permissions:
contents: read

concurrency:
group: inflow-python-release-${{ github.event_name == 'pull_request' && github.ref || 'manual' }}
cancel-in-progress: false

jobs:
verify:
runs-on: ubuntu-latest
timeout-minutes: 25
outputs:
version: ${{ steps.version.outputs.version }}
defaults:
run:
working-directory: sdk
env:
UV_PYTHON: '3.14'
steps:
- uses: actions/checkout@v7
with:
path: sdk
fetch-depth: 0
persist-credentials: false
- uses: actions/setup-python@v7
with:
python-version: '3.14'
- uses: astral-sh/setup-uv@v10.2.0
with:
version: '0.11.8'
- uses: actions/setup-node@v7
with:
node-version: 24
- name: Validate release
id: version
env:
PUBLISH: ${{ github.event_name == 'workflow_dispatch' && !inputs.dry_run }}
run: |
if [[ "$PUBLISH" == true && ( "$GITHUB_REF" != refs/heads/main || "$GITHUB_REPOSITORY" != inflowpayai/inflow-python ) ]]; then
echo 'Publishing requires the upstream main branch.' >&2
exit 1
fi
version=$(python scripts/release.py)
if git show-ref --verify --quiet "refs/tags/v$version"; then
test "$(git rev-parse "v$version^{commit}")" = "$GITHUB_SHA"
fi
echo "version=$version" >> "$GITHUB_OUTPUT"
- name: Read verification pins
id: pins
run: |
node --input-type=module -e '
import { readFileSync, appendFileSync } from "node:fs";
for (const [name, file] of [["contract", "conformance/inflow-specs.lock.json"], ["node", "interop/node.lock.json"]]) {
const { revision } = JSON.parse(readFileSync(file));
if (!/^[0-9a-f]{40}$/.test(revision)) throw new Error("Invalid revision");
appendFileSync(process.env.GITHUB_OUTPUT, `${name}=${revision}\n`);
}
'
- uses: actions/checkout@v7
with:
repository: inflowpayai/inflow-specs
ref: ${{ steps.pins.outputs.contract }}
path: contract
persist-credentials: false
- uses: actions/checkout@v7
with:
repository: inflowpayai/inflow-node
ref: ${{ steps.pins.outputs.node }}
path: node-sdk
persist-credentials: false
- uses: pnpm/action-setup@v6
with:
package_json_file: node-sdk/package.json
- run: pnpm install --frozen-lockfile
working-directory: node-sdk
- run: pnpm build
working-directory: node-sdk
- run: pnpm install --frozen-lockfile
working-directory: contract
- name: Verify candidate and build distributions
run: |
make sync
make verify
node --test scripts/conformance.test.mjs
mkdir -p "$RUNNER_TEMP/release-reports"
node scripts/conformance.mjs --contract-root ../contract --output-dir "$RUNNER_TEMP/release-reports"
node scripts/interoperability.mjs ../node-sdk "$RUNNER_TEMP/release-reports/interoperability.json"
make build
uv run --locked python scripts/verify_distribution.py --dist-dir dist
python scripts/release.py --dist-dir dist
cp coverage.json "$RUNNER_TEMP/release-reports/coverage.json"
(cd dist && sha256sum * > "$RUNNER_TEMP/release-reports/SHA256SUMS")
- uses: actions/upload-artifact@v7
with:
name: inflow-python-distributions
path: sdk/dist/*
if-no-files-found: error
retention-days: 30
- uses: actions/upload-artifact@v7
if: always()
with:
name: inflow-python-release-evidence
path: ${{ runner.temp }}/release-reports/*
if-no-files-found: warn
retention-days: 30

publish:
needs: verify
if: github.event_name == 'workflow_dispatch' && !inputs.dry_run && github.ref == 'refs/heads/main' && github.repository == 'inflowpayai/inflow-python'
environment: release
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
id-token: write
attestations: write
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-python@v7
with:
python-version: '3.14'
- uses: actions/download-artifact@v8
with:
name: inflow-python-distributions
path: dist
- uses: actions/download-artifact@v8
with:
name: inflow-python-release-evidence
path: reports
- run: sha256sum --check ../reports/SHA256SUMS
working-directory: dist
- run: python scripts/release.py --dist-dir dist
- uses: actions/attest-build-provenance@v4
with:
subject-path: dist/*
- uses: pypa/gh-action-pypi-publish@release/v1
with:
skip-existing: true

finalize:
needs: [verify, publish]
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: write
env:
VERSION: ${{ needs.verify.outputs.version }}
UV_PYTHON: '3.14'
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
persist-credentials: false
- uses: actions/setup-python@v7
with:
python-version: '3.14'
- uses: astral-sh/setup-uv@v10.2.0
with:
version: '0.11.8'
- uses: actions/download-artifact@v8
with:
name: inflow-python-distributions
path: dist
- uses: actions/download-artifact@v8
with:
name: inflow-python-release-evidence
path: reports
- name: Verify published artifacts and consumer
run: |
(cd dist && sha256sum --check ../reports/SHA256SUMS)
python scripts/release.py --dist-dir dist --complete
python -m pip download --no-deps --only-binary=:all: --index-url https://pypi.org/simple "inflowpay==$VERSION" -d registry-dist
test "$(sha256sum registry-dist/*.whl | cut -d ' ' -f1)" = "$(sha256sum dist/*.whl | cut -d ' ' -f1)"
make sync
uv run --locked python scripts/verify_distribution.py --dist-dir registry-dist
- name: Create immutable tag and GitHub release
env:
GH_TOKEN: ${{ github.token }}
run: |
tag="v$VERSION"
if git show-ref --verify --quiet "refs/tags/$tag"; then
test "$(git rev-parse "$tag^{commit}")" = "$GITHUB_SHA"
else
object=$(gh api "repos/$GITHUB_REPOSITORY/git/tags" -f tag="$tag" -f message="InFlow Python $tag" -f object="$GITHUB_SHA" -f type=commit --jq .sha)
gh api "repos/$GITHUB_REPOSITORY/git/refs" -f ref="refs/tags/$tag" -f sha="$object"
fi
gh release create "$tag" dist/* reports/* --title "InFlow Python $tag" --generate-notes --verify-tag
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -726,3 +726,6 @@ no timed capability refresh or background polling.
The [Python–Node interoperability suite](https://github.com/inflowpayai/inflow-python/blob/main/interop/README.md) exercises Buyers and
Sellers from both SDKs over local HTTP, including payment rejection and settlement
failure. It uses a synthetic InFlow platform and does not make live payments.

Maintainers can follow the [release instructions](https://github.com/inflowpayai/inflow-python/blob/main/RELEASING.md)
for versioning, Trusted Publishing setup, dry-runs, and publication.
59 changes: 59 additions & 0 deletions RELEASING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# Releasing inflowpay

The distribution and import name are `inflowpay`. Its version is defined in
`pyproject.toml`; release tags use `v` followed by that version, such as `v0.1.0`.
Update the version and refresh `uv.lock` in a reviewed pull request before each
release. Merging code does not publish a package.

## One-time PyPI setup

Sign in to [PyPI Publishing](https://pypi.org/manage/account/publishing/) and add
a pending GitHub publisher with these exact values:

| Field | Value |
| ----------------- | --------------- |
| PyPI project | `inflowpay` |
| Owner | `inflowpayai` |
| Repository | `inflow-python` |
| Workflow filename | `release.yml` |
| Environment | `release` |

Use the workflow filename alone, not `.github/workflows/release.yml`. No PyPI API
token or GitHub publishing secret is needed. The pending publisher creates the
project on its first successful upload; it does not reserve the name beforehand.
For an existing project, configure the same publisher under its Publishing page.
See [PyPI's setup instructions](https://docs.pypi.org/trusted-publishers/creating-a-project-through-oidc/).

The GitHub `release` environment allows deployment only from `main`. Publishing
also requires an explicit manual workflow run with `dry_run` disabled.

## Verify and publish

1. Merge the version changes and ensure the checks are green.
2. Open [the Release workflow](https://github.com/inflowpayai/inflow-python/actions/workflows/release.yml).
3. Select **Run workflow**, choose **main**, and leave **dry_run** checked.
4. Review the successful run and its distribution and release-evidence artifacts.
The workflow runs repository checks, pinned shared conformance and Node
interoperability, builds a wheel from the source distribution, and installs
the exact candidate wheel into isolated consumers. A dry-run creates no tag,
GitHub release, or PyPI upload.
5. With release approval, run the workflow on **main** with **dry_run** unchecked.
6. Confirm the `publish` and `finalize` jobs succeed. Finalization compares PyPI
artifact hashes, installs the registry wheel outside the checkout, then creates
the immutable tag and GitHub release with the reports and distributions.

The workflow passes verified distributions between jobs; the publishing job does
not rebuild them. GitHub build provenance and PyPI publishing attestations accompany
the upload. Pull requests affecting release configuration run verification only.

## Recover a failed run

Use **Re-run failed jobs** on the same workflow run to retain its verified
artifacts. Existing PyPI files are accepted only when their hashes match those
artifacts. A different file under the same version fails rather than replacing it.
If PyPI publication succeeded but finalization failed, rerun finalization instead
of uploading again. A registry propagation delay can require retrying that job.

Never move a released tag or delete and republish a package version. If the source
or artifacts need changing, prepare a new version in a pull request. A successful
release is not an invitation to rerun it: the GitHub release already exists.
1 change: 1 addition & 0 deletions scripts/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Repository verification and release tools."""
65 changes: 65 additions & 0 deletions scripts/release.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
"""Validate release artifacts and refuse conflicting PyPI uploads."""

import argparse
import hashlib
import json
import re
import tomllib
from pathlib import Path
from urllib.error import HTTPError
from urllib.request import urlopen


def project_version(project: Path) -> str:
with project.open("rb") as source:
metadata = tomllib.load(source)["project"]
version = metadata["version"]
if (
metadata["name"] != "inflowpay"
or not isinstance(version, str)
or not re.fullmatch(r"(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)", version)
):
raise ValueError("Expected inflowpay with a stable semantic version")
return version


def check_registry(directory: Path, version: str, *, complete: bool = False) -> None:
expected = {f"inflowpay-{version}.tar.gz", f"inflowpay-{version}-py3-none-any.whl"}
files = {path.name: path for path in directory.iterdir()}
if set(files) != expected or not all(path.is_file() for path in files.values()):
raise ValueError("Expected exactly the release wheel and source distribution")
try:
with urlopen(f"https://pypi.org/pypi/inflowpay/{version}/json", timeout=30) as response:
published = json.load(response)["urls"]
except HTTPError as error:
if error.code != 404:
raise
published = []
found = set()
for entry in published:
name = entry["filename"]
if (
name not in files
or entry["digests"]["sha256"] != hashlib.sha256(files[name].read_bytes()).hexdigest()
):
raise ValueError("PyPI contains different artifacts for this version")
found.add(name)
if complete and found != expected:
raise ValueError("PyPI does not yet contain both verified artifacts")


def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--dist-dir", type=Path)
parser.add_argument("--complete", action="store_true")
arguments = parser.parse_args()
version = project_version(Path(__file__).resolve().parents[1] / "pyproject.toml")
if arguments.dist_dir:
check_registry(arguments.dist_dir, version, complete=arguments.complete)
elif arguments.complete:
parser.error("--complete requires --dist-dir")
print(version)


if __name__ == "__main__":
main()
16 changes: 11 additions & 5 deletions scripts/verify_distribution.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Build from source and verify the wheel outside the checkout."""
"""Verify distribution files in isolated consumers outside the checkout."""

import argparse
import os
import shutil
import subprocess
Expand Down Expand Up @@ -103,13 +104,18 @@ async def check_seller():


def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--dist-dir", type=Path)
arguments = parser.parse_args()
repository = Path(__file__).resolve().parents[1]
with tempfile.TemporaryDirectory(prefix="inflowpay-consumer-") as directory:
temporary = Path(directory)
output = temporary / "dist"
subprocess.run(
[sys.executable, "-m", "build", "--outdir", str(output), str(repository)], check=True
)
output = arguments.dist_dir.resolve() if arguments.dist_dir else temporary / "dist"
if arguments.dist_dir is None:
subprocess.run(
[sys.executable, "-m", "build", "--outdir", str(output), str(repository)],
check=True,
)
artifacts = sorted(output.iterdir())
subprocess.run(
[sys.executable, "-m", "twine", "check", "--strict", *map(str, artifacts)], check=True
Expand Down
Loading
Loading