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())