diff --git a/.github/scripts/check_version_bump.py b/.github/scripts/check_version_bump.py new file mode 100644 index 0000000..8a93f6a --- /dev/null +++ b/.github/scripts/check_version_bump.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 +""" +Check version bump between two commits for pyproject.toml and src/quanteval/__init__.py. + +Writes two outputs to the GitHub Actions runner via the GITHUB_OUTPUT file: +- should_release: 'true' or 'false' +- version: the new version string when should_release is 'true' + +This script is intended to be invoked from a workflow step which sets +the environment variables `GITHUB_BEFORE` and `GITHUB_AFTER`. +""" +import os +import re +import subprocess +import sys + + +def read_at(sha, path): + # If sha is empty or all zeros, fallback to reading from the working tree + if not sha or set(sha) == {"0"}: + try: + with open(path, "r", encoding="utf-8") as f: + return f.read() + except Exception: + return "" + try: + out = subprocess.check_output(["git", "show", f"{sha}:{path}"], stderr=subprocess.DEVNULL) + return out.decode("utf-8") + except subprocess.CalledProcessError: + try: + with open(path, "r", encoding="utf-8") as f: + return f.read() + except Exception: + return "" + + +def extract_version_pyproject(text: str) -> str: + if not text: + return "" + m = re.search(r"^version\s*=\s*['\"]([^'\"]+)['\"]", text, re.M) + if m: + return m.group(1) + m = re.search(r"^\s*version\s*=\s*['\"]([^'\"]+)['\"]", text, re.M) + return m.group(1) if m else "" + + +def extract_version_init(text: str) -> str: + if not text: + return "" + m = re.search(r"__version__\s*=\s*['\"]([^'\"]+)['\"]", text) + return m.group(1) if m else "" + + +def write_output(pairs: dict): + # Write key=value pairs to a temp file so the workflow can load them. + out_file = os.environ.get("CHECK_OUTPUT_FILE", "/tmp/check_version_output.txt") + try: + with open(out_file, "a", encoding="utf-8") as f: + for k, v in pairs.items(): + line = f"{k}={v}\n" + f.write(line) + # also print for logs + print(line.strip()) + except Exception: + # fallback to stdout only + for k, v in pairs.items(): + print(f"{k}={v}") + + +def main(): + before = os.environ.get("GITHUB_BEFORE", "") + after = os.environ.get("GITHUB_AFTER", os.environ.get("GITHUB_SHA", "")) + + py = "pyproject.toml" + init = "src/quanteval/__init__.py" + + old_py = read_at(before, py) + new_py = read_at(after, py) + old_init = read_at(before, init) + new_init = read_at(after, init) + + old_py_v = extract_version_pyproject(old_py) + new_py_v = extract_version_pyproject(new_py) + old_init_v = extract_version_init(old_init) + new_init_v = extract_version_init(new_init) + + print("old_py_v=", old_py_v) + print("new_py_v=", new_py_v) + print("old_init_v=", old_init_v) + print("new_init_v=", new_init_v) + + should_release = False + version = "" + if new_py_v and new_init_v and new_py_v == new_init_v: + if new_py_v != old_py_v and new_init_v != old_init_v: + should_release = True + version = new_py_v + + if should_release: + write_output({"should_release": "true", "version": version}) + else: + write_output({"should_release": "false"}) + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index df257c1..31c62e3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,9 +1,12 @@ name: Release +# Trigger when version files are modified or via manual dispatch on: push: - tags: - - 'v*' + # Only trigger when the two files that declare the package version are changed + paths: + - 'pyproject.toml' + - 'src/quanteval/__init__.py' workflow_dispatch: inputs: dry_run: @@ -14,14 +17,17 @@ on: permissions: contents: write packages: write + # allow creating tags/releases + id-token: write jobs: release: runs-on: ubuntu-latest - steps: - name: Checkout uses: actions/checkout@v6 + with: + fetch-depth: 0 - name: Set up Python uses: actions/setup-python@v5 @@ -29,26 +35,68 @@ jobs: python-version: '3.13' cache: pip + - name: Check version bump in pyproject.toml and __init__.py + id: check + env: + GITHUB_BEFORE: ${{ github.event.before }} + GITHUB_AFTER: ${{ github.sha }} + CHECK_OUTPUT_FILE: /tmp/check_version_output.txt + run: | + set -euo pipefail + # Run the check script which writes key=value pairs to $CHECK_OUTPUT_FILE + python .github/scripts/check_version_bump.py + # Show the file for logs + cat "$CHECK_OUTPUT_FILE" || true + # Append to GITHUB_OUTPUT so the step outputs are available + if [ -n "${GITHUB_OUTPUT-}" ] && [ -f "$CHECK_OUTPUT_FILE" ]; then + cat "$CHECK_OUTPUT_FILE" >> "$GITHUB_OUTPUT" + fi + + - name: Stop if not a release commit + if: ${{ steps.check.outputs.should_release == 'false' }} + run: | + echo "No version bump detected or versions do not match; skipping release." + exit 0 + + - name: Create tag for release + if: ${{ steps.check.outputs.should_release == 'true' }} + env: + VERSION: ${{ steps.check.outputs.version }} + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + TAG=v${VERSION} + # avoid error if tag already exists + if git rev-parse "$TAG" >/dev/null 2>&1; then + echo "Tag $TAG already exists, skipping tag creation." + else + git tag -a "$TAG" -m "Release $TAG" + git push origin "$TAG" + fi + - name: Install build dependencies + if: ${{ steps.check.outputs.should_release == 'true' }} run: | python -m pip install --upgrade pip python -m pip install -e ".[dev]" - name: Build release artifacts + if: ${{ steps.check.outputs.should_release == 'true' }} run: python -m build - name: Validate artifacts + if: ${{ steps.check.outputs.should_release == 'true' }} run: twine check dist/* - name: Publish to TestPyPI (dry-run) - if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.dry_run == 'true' }} + if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.dry_run == 'true' && steps.check.outputs.should_release == 'true' }} run: | python -m pip install --upgrade pip python -m pip install twine twine upload --repository-url https://test.pypi.org/legacy/ dist/* -u __token__ -p ${{ secrets.TEST_PYPI_TOKEN }} --skip-existing --verbose - name: Publish to PyPI - if: ${{ !(github.event_name == 'workflow_dispatch' && github.event.inputs.dry_run == 'true') }} + if: ${{ steps.check.outputs.should_release == 'true' && !(github.event_name == 'workflow_dispatch' && github.event.inputs.dry_run == 'true') }} uses: pypa/gh-action-pypi-publish@v1.5.0 with: skip-existing: true @@ -57,10 +105,13 @@ jobs: password: ${{ secrets.PYPI_TOKEN }} - name: Create GitHub release + if: ${{ steps.check.outputs.should_release == 'true' }} uses: softprops/action-gh-release@v2 env: GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }} with: + tag_name: v${{ steps.check.outputs.version }} + name: Release v${{ steps.check.outputs.version }} generate_release_notes: true files: | dist/* diff --git a/pyproject.toml b/pyproject.toml index ac45dd6..ab3984e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "quanteval" -version = "1.0.0" +version = "1.0.1" description = "Quantitative strategy and factor evaluation toolkit for Chinese A-share and Hong Kong (HKEX) markets" readme = "README.md" requires-python = ">=3.11" diff --git a/src/quanteval/__init__.py b/src/quanteval/__init__.py index 655f062..0ec438a 100644 --- a/src/quanteval/__init__.py +++ b/src/quanteval/__init__.py @@ -6,7 +6,7 @@ License: MIT """ -__version__ = '1.0.0' +__version__ = '1.0.1' # Core components from quanteval.core.strategy import Strategy