From 3a40083e4ace9ae532910d86f79d358619983ec4 Mon Sep 17 00:00:00 2001 From: luisleo526 Date: Sat, 11 Jul 2026 02:14:25 +0800 Subject: [PATCH] Add manual semantic releases --- .github/release.yml | 22 ++++ .github/workflows/release.yml | 188 ++++++++++++++++++++++++++++++++-- docs/releasing.md | 53 +++++++--- scripts/bump_version.py | 110 ++++++++++++++++++++ tests/test_release.py | 65 ++++++++++++ 5 files changed, 417 insertions(+), 21 deletions(-) create mode 100644 .github/release.yml create mode 100644 scripts/bump_version.py diff --git a/.github/release.yml b/.github/release.yml new file mode 100644 index 0000000..a01f1f2 --- /dev/null +++ b/.github/release.yml @@ -0,0 +1,22 @@ +changelog: + exclude: + labels: + - skip-changelog + categories: + - title: Breaking changes + labels: + - breaking-change + - title: Features + labels: + - enhancement + - feature + - title: Fixes + labels: + - bug + - fix + - title: Documentation + labels: + - documentation + - title: Other merged changes + labels: + - "*" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 35d103f..7e2e9d3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,28 +1,171 @@ name: Release on: - release: - types: [published] + workflow_dispatch: + inputs: + bump: + description: Semantic version component to increment + required: true + default: patch + type: choice + options: + - patch + - minor + - major permissions: contents: read +concurrency: + group: release + cancel-in-progress: false + jobs: + prepare: + runs-on: ubuntu-latest + permissions: + contents: write + issues: write + pull-requests: write + outputs: + published: ${{ steps.release.outputs.published }} + sha: ${{ steps.release.outputs.sha }} + tag: ${{ steps.release.outputs.tag }} + version: ${{ steps.release.outputs.version }} + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + ref: ${{ github.sha }} + - uses: actions/setup-python@v6 + with: + python-version: "3.13" + - name: Create and merge version bump + id: release + env: + BUMP: ${{ inputs.bump }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + run: | + set -euo pipefail + if [[ "$GITHUB_REF_NAME" != "$DEFAULT_BRANCH" ]]; then + echo "Release must be dispatched from $DEFAULT_BRANCH, not $GITHUB_REF_NAME" >&2 + exit 1 + fi + + current_version="$(python scripts/bump_version.py current)" + version="$(python scripts/bump_version.py "$BUMP")" + tag="v$version" + git fetch origin "$DEFAULT_BRANCH" --tags + + if sha="$(git rev-list -n 1 "$tag" 2>/dev/null)" && [[ -n "$sha" ]]; then + echo "Reusing existing tag $tag at $sha" + else + git show "origin/$DEFAULT_BRANCH:pyproject.toml" > "$RUNNER_TEMP/main-pyproject.toml" + main_version="$(python scripts/bump_version.py current --pyproject "$RUNNER_TEMP/main-pyproject.toml")" + if [[ "$main_version" == "$version" ]]; then + sha="$(git rev-parse "origin/$DEFAULT_BRANCH")" + echo "Reusing version bump already merged at $sha" + elif [[ "$main_version" != "$current_version" ]]; then + echo "main moved from version $current_version to $main_version; dispatch again" >&2 + exit 1 + else + branch="release/$tag-$GITHUB_RUN_ID" + git switch --create "$branch" "origin/$DEFAULT_BRANCH" + written_version="$(python scripts/bump_version.py "$BUMP" --write)" + [[ "$written_version" == "$version" ]] + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add pyproject.toml + git commit -m "Release $tag" + head_sha="$(git rev-parse HEAD)" + git push --set-upstream origin "$branch" + + gh label create skip-changelog \ + --description "Exclude this PR from generated release notes" \ + --color "ededed" \ + --force + pr_url="$(gh pr create \ + --base "$DEFAULT_BRANCH" \ + --head "$branch" \ + --title "Release $tag" \ + --body "Automated **$BUMP** version bump from $current_version to $version.")" + pr_number="${pr_url##*/}" + gh pr edit "$pr_number" --add-label skip-changelog + mergeable=UNKNOWN + for _attempt in {1..30}; do + mergeable="$(gh pr view "$pr_number" --json mergeable --jq ".mergeable")" + [[ "$mergeable" == "MERGEABLE" ]] && break + if [[ "$mergeable" == "CONFLICTING" ]]; then + echo "Release PR $pr_number conflicts with $DEFAULT_BRANCH" >&2 + exit 1 + fi + sleep 2 + done + if [[ "$mergeable" != "MERGEABLE" ]]; then + echo "Release PR $pr_number did not become mergeable" >&2 + exit 1 + fi + gh pr merge "$pr_number" \ + --squash \ + --delete-branch \ + --match-head-commit "$head_sha" \ + --subject "Release $tag" \ + --body "Automated semantic version bump." + sha="$(gh pr view "$pr_number" --json mergeCommit --jq ".mergeCommit.oid")" + fi + fi + + pypi_status="$(curl \ + --silent \ + --output /dev/null \ + --write-out '%{http_code}' \ + --retry 3 \ + --retry-all-errors \ + "https://pypi.org/pypi/pineforge-data/$version/json")" + case "$pypi_status" in + 200) published=true ;; + 404) published=false ;; + *) + echo "Unexpected PyPI status $pypi_status while checking $version" >&2 + exit 1 + ;; + esac + + { + echo "published=$published" + echo "sha=$sha" + echo "tag=$tag" + echo "version=$version" + } >> "$GITHUB_OUTPUT" + printf "Prepared %s at %s (already on PyPI: %s).\n" \ + "$tag" "$sha" "$published" >> "$GITHUB_STEP_SUMMARY" + build: + if: needs.prepare.outputs.published != 'true' + needs: prepare runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 with: - ref: ${{ github.event.release.tag_name }} + ref: ${{ needs.prepare.outputs.sha }} persist-credentials: false - uses: actions/setup-python@v6 with: python-version: "3.13" cache: pip - run: python -m pip install --upgrade pip - - run: python -m pip install '.[release]' - - name: Verify release tag - run: python scripts/verify_release.py "${{ github.event.release.tag_name }}" + - run: python -m pip install -e '.[dev,ccxt,release]' + - name: Verify release tag and source + run: python scripts/verify_release.py "${{ needs.prepare.outputs.tag }}" + - name: Run static and unit checks + run: | + ruff check . + mypy src + pytest + - name: Run Docker integration checks + run: PINEFORGE_DOCKER_TEST=1 pytest tests/test_docker_integration.py - name: Build wheel and source distribution run: python -m build - name: Check distribution metadata @@ -39,8 +182,39 @@ jobs: path: dist/ if-no-files-found: error + github-release: + if: needs.prepare.outputs.published != 'true' + needs: + - prepare + - build + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Create GitHub Release with generated notes + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + SHA: ${{ needs.prepare.outputs.sha }} + TAG: ${{ needs.prepare.outputs.tag }} + run: | + set -euo pipefail + if gh release view "$TAG" >/dev/null 2>&1; then + echo "GitHub Release $TAG already exists" + else + gh release create "$TAG" \ + --target "$SHA" \ + --title "pineforge-data $TAG" \ + --generate-notes \ + --latest + fi + publish: - needs: build + if: needs.prepare.outputs.published != 'true' + needs: + - prepare + - build + - github-release runs-on: ubuntu-latest environment: name: pypi diff --git a/docs/releasing.md b/docs/releasing.md index af7d821..5f8c522 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -28,10 +28,8 @@ the `main` branch to `https://pineforge-4pass.github.io/pineforge-data/`. ## Release checklist -1. Update `project.version` in `pyproject.toml` using a valid Python package - version. -2. Update user-facing documentation and merge the release changes to `main`. -3. Run the complete local checks: +1. Merge every intended code and documentation change to `main`. +2. Run the complete local checks when preparing substantial release changes: ```bash python -m pip install -e '.[dev,ccxt,database,server,docs,release]' @@ -43,20 +41,47 @@ the `main` branch to `https://pineforge-4pass.github.io/pineforge-data/`. python -m twine check dist/* ``` -4. Create a GitHub Release whose tag is exactly `v`, such as - `v0.1.0`. -5. Review the generated wheel and source-distribution artifact, then approve - the `pypi` environment deployment. -6. Confirm the version and project links on PyPI and install it into a clean +3. Open the repository's **Actions** tab, select **Release**, and choose + **Run workflow** from `main`. +4. Select the semantic version component: + + | Choice | Example | + |---|---| + | `patch` | `1.2.3` → `1.2.4` | + | `minor` | `1.2.3` → `1.3.0` | + | `major` | `1.2.3` → `2.0.0` | + +5. The workflow creates and squash-merges a version-only PR, validates the + merged source, and creates the matching `v` GitHub Release. +6. Review the generated wheel, source distribution, and release notes, then + approve the protected `pypi` environment deployment. +7. Confirm the version and project links on PyPI and install it into a clean environment. ## Workflow safeguards -The release workflow runs only for a published GitHub Release. Before upload, -it verifies that the tag exactly matches `v` plus `project.version`, builds the -wheel and source archive once, validates their long-description metadata, and -installs the wheel in a clean virtual environment. The publish job receives -only those validated artifacts and has only `id-token: write` permission. +The release workflow is manually dispatched and serialized so two version +bumps cannot race. It refuses dispatches from branches other than `main`. Since +the organization requires changes to enter `main` through a pull request, the +workflow creates a dedicated release PR instead of pushing directly. + +The generated notes include every merged PR since the previous GitHub Release. +Closed-but-unmerged PRs are intentionally excluded because their changes were +not shipped. The mechanical version-bump PR carries `skip-changelog` and is +also excluded. Labels group breaking changes, features, fixes, documentation, +and all other merged work. + +Before upload, the workflow verifies that the prospective tag exactly matches +`v` plus `project.version`, runs static checks, unit tests, and Docker +integration tests, builds the wheel and source archive once, validates their +long-description metadata, and installs the wheel in a clean virtual +environment. Only then does it create the GitHub Release and request approval +for PyPI. The publish job receives the validated artifacts and has only +`id-token: write` permission. + +Reruns reuse an already merged bump or tag. If the version is already present +on PyPI, build and publication jobs are skipped instead of attempting a +duplicate immutable upload. PyPI versions are immutable. If a release is wrong, increment the package version and publish a correction; do not attempt to replace an existing file. diff --git a/scripts/bump_version.py b/scripts/bump_version.py new file mode 100644 index 0000000..4d6d0fb --- /dev/null +++ b/scripts/bump_version.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python3 +"""Read or bump the PEP 621 project version using semantic-version rules.""" + +from __future__ import annotations + +import argparse +import re +import sys +import tomllib +from pathlib import Path +from typing import Any, Literal + +ROOT = Path(__file__).resolve().parents[1] +SEMVER = re.compile(r"^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$") +VERSION_LINE = re.compile(r'^(\s*version\s*=\s*")([^"]+)(".*)$') +BumpPart = Literal["patch", "minor", "major"] + + +def project_version(pyproject: Path) -> str: + """Read and validate ``project.version`` from a pyproject file.""" + + with pyproject.open("rb") as handle: + value: dict[str, Any] = tomllib.load(handle) + project = value.get("project") + if not isinstance(project, dict): + raise ValueError(f"missing [project] table in {pyproject}") + version = project.get("version") + if not isinstance(version, str) or not version.strip(): + raise ValueError(f"project.version must be a non-empty string in {pyproject}") + return version + + +def bumped_version(version: str, part: BumpPart) -> str: + """Return the next semantic version for ``part``.""" + + match = SEMVER.fullmatch(version) + if match is None: + raise ValueError( + f"project version {version!r} is not plain MAJOR.MINOR.PATCH semantic versioning" + ) + major, minor, patch = (int(value) for value in match.groups()) + if part == "patch": + patch += 1 + elif part == "minor": + minor += 1 + patch = 0 + else: + major += 1 + minor = 0 + patch = 0 + return f"{major}.{minor}.{patch}" + + +def replace_project_version(text: str, current: str, replacement: str) -> str: + """Replace exactly one version assignment inside the PEP 621 project table.""" + + lines = text.splitlines(keepends=True) + in_project = False + replacements = 0 + for index, line in enumerate(lines): + body = line.rstrip("\r\n") + ending = line[len(body) :] + section = re.match(r"^\s*\[([^]]+)]\s*$", body) + if section is not None: + in_project = section.group(1).strip() == "project" + continue + if not in_project: + continue + match = VERSION_LINE.match(body) + if match is None: + continue + if match.group(2) != current: + raise ValueError( + f"project.version changed while editing: expected {current!r}, " + f"found {match.group(2)!r}" + ) + lines[index] = f"{match.group(1)}{replacement}{match.group(3)}{ending}" + replacements += 1 + if replacements != 1: + raise ValueError(f"expected exactly one project.version assignment, found {replacements}") + return "".join(lines) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("part", choices=("current", "patch", "minor", "major")) + parser.add_argument("--pyproject", type=Path, default=ROOT / "pyproject.toml") + parser.add_argument("--write", action="store_true", help="write the bumped version") + args = parser.parse_args(argv) + try: + current = project_version(args.pyproject) + if args.part == "current": + if args.write: + raise ValueError("--write requires patch, minor, or major") + result = current + else: + result = bumped_version(current, args.part) + if args.write: + original = args.pyproject.read_text(encoding="utf-8") + updated = replace_project_version(original, current, result) + args.pyproject.write_text(updated, encoding="utf-8") + except (OSError, tomllib.TOMLDecodeError, ValueError) as exc: + print(f"error: {exc}", file=sys.stderr) + return 1 + print(result) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_release.py b/tests/test_release.py index d6c8428..18335d4 100644 --- a/tests/test_release.py +++ b/tests/test_release.py @@ -6,6 +6,7 @@ ROOT = Path(__file__).resolve().parents[1] VERIFY_RELEASE = ROOT / "scripts" / "verify_release.py" +BUMP_VERSION = ROOT / "scripts" / "bump_version.py" def test_release_script_accepts_matching_pep_621_version(tmp_path: Path) -> None: @@ -36,3 +37,67 @@ def test_release_script_rejects_mismatched_tag(tmp_path: Path) -> None: assert result.returncode == 1 assert "expected 'v1.2.3'" in result.stderr + + +def test_version_script_reads_and_bumps_each_semver_part(tmp_path: Path) -> None: + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text('[project]\nname = "example"\nversion = "1.2.3"\n', encoding="utf-8") + + cases = ( + ("current", "1.2.3"), + ("patch", "1.2.4"), + ("minor", "1.3.0"), + ("major", "2.0.0"), + ) + for part, expected in cases: + result = subprocess.run( + [sys.executable, str(BUMP_VERSION), part, "--pyproject", str(pyproject)], + text=True, + capture_output=True, + check=False, + ) + assert result.returncode == 0 + assert result.stdout.strip() == expected + + +def test_version_script_writes_only_project_version(tmp_path: Path) -> None: + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text( + '[project]\nversion = "1.2.3"\n\n[tool.example]\nversion = "keep-me"\n', + encoding="utf-8", + ) + + result = subprocess.run( + [ + sys.executable, + str(BUMP_VERSION), + "minor", + "--write", + "--pyproject", + str(pyproject), + ], + text=True, + capture_output=True, + check=False, + ) + + assert result.returncode == 0 + assert result.stdout.strip() == "1.3.0" + assert pyproject.read_text(encoding="utf-8") == ( + '[project]\nversion = "1.3.0"\n\n[tool.example]\nversion = "keep-me"\n' + ) + + +def test_version_script_rejects_non_plain_semver(tmp_path: Path) -> None: + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text('[project]\nversion = "1.2.3rc1"\n', encoding="utf-8") + + result = subprocess.run( + [sys.executable, str(BUMP_VERSION), "patch", "--pyproject", str(pyproject)], + text=True, + capture_output=True, + check=False, + ) + + assert result.returncode == 1 + assert "not plain MAJOR.MINOR.PATCH" in result.stderr