diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..8e4e1ad --- /dev/null +++ b/.github/workflows/release.yml @@ -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 diff --git a/README.md b/README.md index 4af16a6..15023de 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..222d839 --- /dev/null +++ b/RELEASING.md @@ -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. diff --git a/scripts/__init__.py b/scripts/__init__.py new file mode 100644 index 0000000..f3087b7 --- /dev/null +++ b/scripts/__init__.py @@ -0,0 +1 @@ +"""Repository verification and release tools.""" diff --git a/scripts/release.py b/scripts/release.py new file mode 100644 index 0000000..1c67aeb --- /dev/null +++ b/scripts/release.py @@ -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() diff --git a/scripts/verify_distribution.py b/scripts/verify_distribution.py index f918f25..35e587e 100644 --- a/scripts/verify_distribution.py +++ b/scripts/verify_distribution.py @@ -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 @@ -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 diff --git a/tests/test_release.py b/tests/test_release.py new file mode 100644 index 0000000..3985a0a --- /dev/null +++ b/tests/test_release.py @@ -0,0 +1,78 @@ +import hashlib +import io +import json +from email.message import Message +from pathlib import Path +from urllib.error import HTTPError + +import pytest +from scripts.release import check_registry, project_version + + +@pytest.mark.parametrize("version", ["0.1.0", "1.0.0", "20.10.3"]) +def test_release_version(tmp_path: Path, version: str) -> None: + project = tmp_path / "pyproject.toml" + project.write_text(f'[project]\nname="inflowpay"\nversion="{version}"\n') + assert project_version(project) == version + + +@pytest.mark.parametrize("version", ["01.0.0", "v1.0.0", "1.0.0rc1", "1.0", "1.0.0+build"]) +def test_invalid_release_version(tmp_path: Path, version: str) -> None: + project = tmp_path / "pyproject.toml" + project.write_text(f'[project]\nname="inflowpay"\nversion="{version}"\n') + with pytest.raises(ValueError): + project_version(project) + + +@pytest.mark.parametrize( + "state", ["absent", "partial", "complete", "conflict", "foreign", "unavailable"] +) +def test_registry_artifact_identity( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, state: str +) -> None: + names = ["inflowpay-0.1.0.tar.gz", "inflowpay-0.1.0-py3-none-any.whl"] + for name in names: + (tmp_path / name).write_bytes(b"verified artifact") + + def response(url: str, *, timeout: int) -> io.BytesIO: + assert url == "https://pypi.org/pypi/inflowpay/0.1.0/json" and timeout == 30 + if state in ("absent", "unavailable"): + raise HTTPError(url, 404 if state == "absent" else 503, "test", Message(), None) + entries = [ + { + "filename": name, + "digests": { + "sha256": hashlib.sha256( + b"different" if state == "conflict" else b"verified artifact" + ).hexdigest() + }, + } + for name in (names[:1] if state == "partial" else names) + ] + if state == "foreign": + entries[0]["filename"] = "other.whl" + return io.BytesIO(json.dumps({"urls": entries}).encode()) + + monkeypatch.setattr("scripts.release.urlopen", response) + if state in ("conflict", "foreign", "unavailable"): + with pytest.raises((ValueError, HTTPError)): + check_registry(tmp_path, "0.1.0") + else: + check_registry(tmp_path, "0.1.0") + if state == "complete": + check_registry(tmp_path, "0.1.0", complete=True) + else: + with pytest.raises(ValueError): + check_registry(tmp_path, "0.1.0", complete=True) + + +def test_missing_artifacts(tmp_path: Path) -> None: + with pytest.raises(ValueError): + check_registry(tmp_path, "0.1.0") + + +def test_wrong_project(tmp_path: Path) -> None: + project = tmp_path / "pyproject.toml" + project.write_text('[project]\nname="another-project"\nversion="0.1.0"\n') + with pytest.raises(ValueError): + project_version(project)