diff --git a/.github/RELEASING-PYTHON.md b/.github/RELEASING-PYTHON.md index 5853038..1f70b67 100644 --- a/.github/RELEASING-PYTHON.md +++ b/.github/RELEASING-PYTHON.md @@ -12,8 +12,8 @@ non-deterministic output; generated payload must never be added to Git. ## Validate without publishing Run **Python Standard Models Release** manually with version `1.0.0`, then run -**Python Native Release** with version `4.1.0`. `workflow_dispatch` validates -artifacts but cannot publish. The native run must pass Linux x86-64, Linux +**Python Native Release** with version `4.1.0`. The default manual mode validates +artifacts without publishing. The native run must pass Linux x86-64, Linux ARM64, macOS universal2, and Windows x86-64. For the Linux paths, maintainers can use `act` with rootless Podman and the diff --git a/.github/act/python-models-standard.json b/.github/act/python-models-standard.json index 7dfd71e..a052239 100644 --- a/.github/act/python-models-standard.json +++ b/.github/act/python-models-standard.json @@ -1,5 +1,6 @@ { "inputs": { - "version": "1.0.0" + "version": "1.0.0", + "publish_existing_to_pypi": false } } diff --git a/.github/act/python-native.json b/.github/act/python-native.json index 0dfd6f0..85f4d3f 100644 --- a/.github/act/python-native.json +++ b/.github/act/python-native.json @@ -1,5 +1,6 @@ { "inputs": { - "version": "4.1.0" + "version": "4.1.0", + "publish_existing_to_pypi": false } } diff --git a/.github/workflows/python-models-standard-release.yml b/.github/workflows/python-models-standard-release.yml index 46ec7ba..ea05ab2 100644 --- a/.github/workflows/python-models-standard-release.yml +++ b/.github/workflows/python-models-standard-release.yml @@ -11,6 +11,11 @@ on: required: true default: '1.0.0' type: string + publish_existing_to_pypi: + description: Publish the existing GitHub Release to PyPI + required: true + default: false + type: boolean permissions: contents: read @@ -22,6 +27,7 @@ concurrency: jobs: build: name: Build and verify standard models + if: github.event_name == 'push' || inputs.publish_existing_to_pypi != true runs-on: ubuntu-latest outputs: version: ${{ steps.release.outputs.version }} @@ -201,6 +207,99 @@ jobs: gh release edit "${RELEASE_TAG}" \ --repo "${GITHUB_REPOSITORY}" --draft=false + publish-pypi: + name: Publish standard models to PyPI + if: >- + always() && + ((github.event_name == 'push' && needs.publish.result == 'success') || + (github.event_name == 'workflow_dispatch' && inputs.publish_existing_to_pypi)) + needs: [build, publish] + runs-on: ubuntu-latest + environment: python-pypi + permissions: + attestations: read + contents: read + id-token: write + + steps: + - name: Select PyPI release source + id: release + shell: bash + env: + BUILT_VERSION: ${{ needs.build.outputs.version }} + BUILT_TAG: ${{ needs.build.outputs.tag }} + REQUESTED_VERSION: ${{ inputs.version }} + run: | + set -euo pipefail + if [[ "${GITHUB_EVENT_NAME}" == 'push' ]]; then + version="${BUILT_VERSION}" + tag="${BUILT_TAG}" + else + version="${REQUESTED_VERSION}" + tag="python-models-standard@${version}" + fi + [[ "${version}" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] + printf 'version=%s\ntag=%s\n' "${version}" "${tag}" >> "${GITHUB_OUTPUT}" + + - name: Download current-run release candidate + if: github.event_name == 'push' + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: python-models-standard-release-${{ steps.release.outputs.version }} + path: release + + - name: Download existing GitHub Release + if: github.event_name == 'workflow_dispatch' + shell: bash + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ steps.release.outputs.tag }} + run: | + set -euo pipefail + mkdir release + gh release download "${RELEASE_TAG}" \ + --repo "${GITHUB_REPOSITORY}" --dir release \ + --pattern '*.whl' --pattern '*.tar.gz' --pattern SHA256SUMS + + - name: Verify and stage PyPI distributions + shell: bash + run: | + set -euo pipefail + cd release + sha256sum --check SHA256SUMS + awk '{print $2}' SHA256SUMS | LC_ALL=C sort > expected-files + [[ "$(wc -l < expected-files)" -eq 2 ]] + if grep -Ev '^[A-Za-z0-9_.+-]+(\.whl|\.tar\.gz)$' expected-files; then + echo 'SHA256SUMS contains an invalid distribution filename.' >&2 + exit 1 + fi + find . -maxdepth 1 -type f \( -name '*.whl' -o -name '*.tar.gz' \) \ + -printf '%f\n' | LC_ALL=C sort > actual-files + diff -u expected-files actual-files + mkdir ../pypi + while IFS= read -r artifact; do + cp -- "${artifact}" ../pypi/ + done < expected-files + + - name: Verify GitHub build provenance + shell: bash + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ steps.release.outputs.tag }} + run: | + set -euo pipefail + for artifact in pypi/*; do + gh attestation verify "${artifact}" \ + --repo "${GITHUB_REPOSITORY}" \ + --signer-workflow "${GITHUB_REPOSITORY}/.github/workflows/python-models-standard-release.yml" \ + --source-ref "refs/tags/${RELEASE_TAG}" + done + + - name: Publish distributions with PyPI Trusted Publishing + uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: pypi/ + publish-index: name: Publish Python package index if: github.event_name == 'push' diff --git a/.github/workflows/python-release.yml b/.github/workflows/python-release.yml index 916a204..50c8d80 100644 --- a/.github/workflows/python-release.yml +++ b/.github/workflows/python-release.yml @@ -11,6 +11,11 @@ on: required: true default: '4.1.0' type: string + publish_existing_to_pypi: + description: Publish the existing GitHub Release to PyPI + required: true + default: false + type: boolean permissions: contents: read @@ -22,6 +27,7 @@ concurrency: jobs: prepare: name: Prepare versioned sources and sdist + if: github.event_name == 'push' || inputs.publish_existing_to_pypi != true runs-on: ubuntu-latest outputs: version: ${{ steps.release.outputs.version }} @@ -419,6 +425,105 @@ jobs: gh release edit "${RELEASE_TAG}" \ --repo "${GITHUB_REPOSITORY}" --draft=false + publish-pypi: + name: Publish native distributions to PyPI + if: >- + always() && + ((github.event_name == 'push' && needs.publish.result == 'success') || + (github.event_name == 'workflow_dispatch' && inputs.publish_existing_to_pypi)) + needs: [prepare, assemble, publish] + runs-on: ubuntu-latest + environment: python-pypi + permissions: + attestations: read + contents: read + id-token: write + + steps: + - name: Select PyPI release source + id: release + shell: bash + env: + BUILT_VERSION: ${{ needs.prepare.outputs.version }} + BUILT_TAG: ${{ needs.prepare.outputs.tag }} + REQUESTED_VERSION: ${{ inputs.version }} + run: | + set -euo pipefail + if [[ "${GITHUB_EVENT_NAME}" == 'push' ]]; then + version="${BUILT_VERSION}" + tag="${BUILT_TAG}" + else + version="${REQUESTED_VERSION}" + tag="python@${version}" + fi + [[ "${version}" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] + printf 'version=%s\ntag=%s\n' "${version}" "${tag}" >> "${GITHUB_OUTPUT}" + + - name: Download current-run release candidate + if: github.event_name == 'push' + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: python-native-release-${{ steps.release.outputs.version }} + path: release + + - name: Download existing GitHub Release + if: github.event_name == 'workflow_dispatch' + shell: bash + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ steps.release.outputs.tag }} + run: | + set -euo pipefail + mkdir release + gh release download "${RELEASE_TAG}" \ + --repo "${GITHUB_REPOSITORY}" --dir release \ + --pattern '*.whl' --pattern '*.tar.gz' --pattern SHA256SUMS + + - name: Verify required model dependency on PyPI + run: >- + curl --fail --silent --show-error + https://pypi.org/pypi/radixor-models-standard/1.0.0/json + > /dev/null + + - name: Verify and stage PyPI distributions + shell: bash + run: | + set -euo pipefail + cd release + sha256sum --check SHA256SUMS + awk '{print $2}' SHA256SUMS | LC_ALL=C sort > expected-files + [[ "$(wc -l < expected-files)" -eq 5 ]] + if grep -Ev '^[A-Za-z0-9_.+-]+(\.whl|\.tar\.gz)$' expected-files; then + echo 'SHA256SUMS contains an invalid distribution filename.' >&2 + exit 1 + fi + find . -maxdepth 1 -type f \( -name '*.whl' -o -name '*.tar.gz' \) \ + -printf '%f\n' | LC_ALL=C sort > actual-files + diff -u expected-files actual-files + mkdir ../pypi + while IFS= read -r artifact; do + cp -- "${artifact}" ../pypi/ + done < expected-files + + - name: Verify GitHub build provenance + shell: bash + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ steps.release.outputs.tag }} + run: | + set -euo pipefail + for artifact in pypi/*; do + gh attestation verify "${artifact}" \ + --repo "${GITHUB_REPOSITORY}" \ + --signer-workflow "${GITHUB_REPOSITORY}/.github/workflows/python-release.yml" \ + --source-ref "refs/tags/${RELEASE_TAG}" + done + + - name: Publish distributions with PyPI Trusted Publishing + uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: pypi/ + publish-index: name: Publish Python package index if: github.event_name == 'push' diff --git a/README.md b/README.md index a693307..2e1137a 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ It also retains the operational advantages of a compiled artifact model: predict For Python, one installation provides the native runtime and the separate standard package of 20 precompiled models: -From PyPI, once publication is enabled: +From PyPI: ```bash python -m pip install --only-binary=:all: radixor diff --git a/docs/python/fast-track.md b/docs/python/fast-track.md index ce46ea7..7cc7fd4 100644 --- a/docs/python/fast-track.md +++ b/docs/python/fast-track.md @@ -19,9 +19,8 @@ standard-model package with 20 precompiled language models. --index-url https://leogalambos.github.io/Radixor/python/simple/ radixor ``` -PyPI publication is pending, and the GitHub index becomes live with the first -Python releases. Until then, follow the source-checkout procedure on -[Installation and Builds](installation.md). +Both indexes provide the same released distributions. Use PyPI as the primary +source or the GitHub index as an independent alternative. Radixor supports CPython 3.9 and newer. A JVM, Java dependency, and source dictionary are not required. diff --git a/docs/python/installation.md b/docs/python/installation.md index f6f13d2..20772d0 100644 --- a/docs/python/installation.md +++ b/docs/python/installation.md @@ -7,17 +7,12 @@ development or unsupported platforms. ## Install from PyPI -PyPI is the intended primary index once the Radixor projects are approved and -published there: +PyPI is the primary package index: ```bash python -m pip install --only-binary=:all: radixor ``` -PyPI publication is not live yet. Until the `radixor` and -`radixor-models-standard` project pages exist, this command cannot install the -project. - ## Install compiled packages from GitHub Python releases are published as immutable GitHub Release assets. A small @@ -29,10 +24,7 @@ python -m pip install --only-binary=:all: \ ``` The index links directly to checksummed wheel assets in GitHub Releases; Pages -does not duplicate the package files. It is not live until the first Python -model and native releases have been published. This was verified before the -initial release: the URL returned HTTP 404 and the repository contained no -Python Release assets. +does not duplicate the package files. Do not configure the GitHub index as an `--extra-index-url`: `pip` does not prioritize one index over another. Use it as the sole `--index-url`, as shown @@ -93,10 +85,8 @@ gh attestation verify radixor--.whl \ Python packages do **not** reuse the OpenPGP key configured for Java/Maven Central publications. Java's `SIGNING_KEY` and `SIGNING_PASSWORD` produce Maven -signatures; Python currently uses release checksums plus GitHub's -identity-bound build-provenance attestation. A future PyPI publication should -use PyPI Trusted Publishing and its supported attestations rather than copying -the Java signing mechanism. +signatures; Python uses release checksums, GitHub identity-bound build +provenance, and PyPI Trusted Publishing with supported digital attestations. ## Build through Gradle diff --git a/docs/python/quick-start.md b/docs/python/quick-start.md index cf51200..ae37d9e 100644 --- a/docs/python/quick-start.md +++ b/docs/python/quick-start.md @@ -26,9 +26,8 @@ source .venv/bin/activate # Windows: .venv\Scripts\activate --index-url https://leogalambos.github.io/Radixor/python/simple/ radixor ``` -PyPI publication is pending. The GitHub option becomes live when the first -Python GitHub Releases populate the Pages-backed package index. See -[Installation and Builds](installation.md) for availability and source builds. +Both indexes provide the same released distributions. See +[Installation and Builds](installation.md) for provenance and source builds. The `radixor` wheel contains code. Its required `radixor-models-standard` dependency contains 20 precompiled models. The diff --git a/python/README.md b/python/README.md index 0ee3a7c..a3ed326 100644 --- a/python/README.md +++ b/python/README.md @@ -22,7 +22,7 @@ fairness notes are in [`benchmarks/`](benchmarks/README.md). ## Installation -From PyPI, once publication is enabled: +From PyPI: ```bash python -m pip install --only-binary=:all: radixor @@ -35,9 +35,9 @@ python -m pip install --only-binary=:all: \ --index-url https://leogalambos.github.io/Radixor/python/simple/ radixor ``` -The GitHub command becomes usable after the first model and native releases -populate that index. See the [installation guide](../docs/python/installation.md) -for current availability and source-checkout builds. +The GitHub index points to the same immutable model and native release assets. +See the [installation guide](../docs/python/installation.md) for provenance and +source-checkout builds. Wheels are provided for Linux, macOS, and Windows (Python 3.9+). The install also resolves the mandatory pure `radixor-models-standard` dependency