diff --git a/.github/workflows/release_python_binding.yml b/.github/workflows/release_python_binding.yml
index ea61789c5..c6b0b89b3 100644
--- a/.github/workflows/release_python_binding.yml
+++ b/.github/workflows/release_python_binding.yml
@@ -16,7 +16,7 @@
# under the License.
# Publish the paimon Python binding to PyPI.
-# Trigger: push tag only (e.g. v0.1.0).
+# Trigger: release tag push or manual dispatch.
# Pre-release tags (containing '-') publish to TestPyPI; release tags publish to PyPI.
#
# Token auth: add secrets PYPI_API_TOKEN / TEST_PYPI_API_TOKEN for publishing.
@@ -29,21 +29,37 @@ on:
- "v[0-9]+.[0-9]+.[0-9]+"
- "v[0-9]+.[0-9]+.[0-9]+-rc[0-9]+"
workflow_dispatch:
+ inputs:
+ tag:
+ description: Release tag, for example v0.3.0-rc2
+ required: true
+ type: string
concurrency:
- group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event_name }}
+ group: ${{ github.workflow }}-${{ inputs.tag || github.ref_name }}
cancel-in-progress: true
permissions:
contents: read
+env:
+ RELEASE_TAG: ${{ inputs.tag || github.ref_name }}
+
jobs:
sdist:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
+ with:
+ ref: ${{ env.RELEASE_TAG }}
+
+ - name: Set Python release version
+ run: >
+ python3 scripts/python_release_version.py
+ --tag "${RELEASE_TAG}"
+ --pyproject bindings/python/pyproject.toml
- - uses: PyO3/maturin-action@v1
+ - uses: PyO3/maturin-action@e83996d129638aa358a18fbd1dfb82f0b0fb5d3b # v1.51.0
with:
working-directory: bindings/python
command: sdist
@@ -69,8 +85,17 @@ jobs:
- { os: ubuntu-latest, target: "aarch64", manylinux: "manylinux_2_28" }
steps:
- uses: actions/checkout@v7
+ with:
+ ref: ${{ env.RELEASE_TAG }}
+
+ - name: Set Python release version
+ shell: bash
+ run: >
+ python3 scripts/python_release_version.py
+ --tag "${RELEASE_TAG}"
+ --pyproject bindings/python/pyproject.toml
- - uses: PyO3/maturin-action@v1
+ - uses: PyO3/maturin-action@e83996d129638aa358a18fbd1dfb82f0b0fb5d3b # v1.51.0
with:
working-directory: bindings/python
target: ${{ matrix.target }}
@@ -143,7 +168,7 @@ jobs:
permissions:
contents: read
needs: [sdist, wheels]
- if: startsWith(github.ref, 'refs/tags/')
+ if: startsWith(inputs.tag || github.ref_name, 'v')
steps:
- uses: actions/download-artifact@v8
with:
@@ -152,8 +177,8 @@ jobs:
path: bindings/python/dist
- name: Publish to TestPyPI
- if: contains(github.ref, '-')
- uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b
+ if: contains(env.RELEASE_TAG, '-')
+ uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0
with:
repository-url: https://test.pypi.org/legacy/
skip-existing: true
@@ -161,8 +186,8 @@ jobs:
password: ${{ secrets.TEST_PYPI_API_TOKEN }}
- name: Publish to PyPI
- if: ${{ !contains(github.ref, '-') }}
- uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b
+ if: ${{ !contains(env.RELEASE_TAG, '-') }}
+ uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0
with:
skip-existing: true
packages-dir: bindings/python/dist
diff --git a/docs/src/release/verifying-a-release-candidate.md b/docs/src/release/verifying-a-release-candidate.md
index 550a9dae6..b8c5c8968 100644
--- a/docs/src/release/verifying-a-release-candidate.md
+++ b/docs/src/release/verifying-a-release-candidate.md
@@ -115,7 +115,7 @@ For any user-facing feature included in a release, we aim to ensure it is functi
- **Python binding:** The RC is published to **TestPyPI**; install the client from TestPyPI and write your own test cases to verify:
```bash
- pip install -i https://test.pypi.org/simple/ pypaimon-rust==${RELEASE_VERSION}
+ pip install -i https://test.pypi.org/simple/ pypaimon-rust==${RELEASE_VERSION}rc${RC_NUM}
```
- **Go binding:** The RC is published as a Go module tag `bindings/go/v${RELEASE_VERSION}-rc${RC_NUM}`; see [Go Binding](https://paimon.apache.org/docs/rust/go-binding/) for usage. Add it to your Go project and write test cases to verify:
diff --git a/scripts/python_release_version.py b/scripts/python_release_version.py
new file mode 100644
index 000000000..58b99b26e
--- /dev/null
+++ b/scripts/python_release_version.py
@@ -0,0 +1,82 @@
+#!/usr/bin/env python3
+
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Set a unique Python version for release-candidate artifacts."""
+
+from __future__ import annotations
+
+import argparse
+import re
+import sys
+import tomllib
+from pathlib import Path
+
+
+TAG_PATTERN = re.compile(r"^v(?P\d+\.\d+\.\d+)(?:-rc(?P[1-9]\d*))?$")
+DYNAMIC_VERSION = 'dynamic = ["version"]'
+
+
+def workspace_version(root: Path) -> str:
+ with (root / "Cargo.toml").open("rb") as source:
+ return str(tomllib.load(source)["workspace"]["package"]["version"])
+
+
+def python_version(tag: str, expected_base: str) -> str:
+ match = TAG_PATTERN.fullmatch(tag)
+ if match is None:
+ raise ValueError(f"invalid release tag: {tag}")
+ base = match.group("base")
+ if base != expected_base:
+ raise ValueError(f"tag {base} does not match workspace {expected_base}")
+ rc = match.group("rc")
+ return base if rc is None else f"{base}rc{rc}"
+
+
+def update_pyproject(path: Path, version: str, expected_base: str) -> None:
+ if version == expected_base:
+ return
+ content = path.read_text(encoding="utf-8")
+ if content.count(DYNAMIC_VERSION) != 1:
+ raise ValueError(f"unexpected dynamic version setting in {path}")
+ path.write_text(
+ content.replace(DYNAMIC_VERSION, f'version = "{version}"', 1),
+ encoding="utf-8",
+ )
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--tag", required=True)
+ parser.add_argument("--pyproject", type=Path)
+ args = parser.parse_args()
+
+ root = Path(__file__).resolve().parents[1]
+ try:
+ base = workspace_version(root)
+ version = python_version(args.tag, base)
+ if args.pyproject is not None:
+ update_pyproject(root / args.pyproject, version, base)
+ except (KeyError, OSError, ValueError, tomllib.TOMLDecodeError) as error:
+ print(f"Python release version failed: {error}", file=sys.stderr)
+ return 1
+
+ print(version)
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())