From a34585b7b73cf07b56cffcdaf5d620195e6c1bb6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Pedro=20Brun?= Date: Fri, 4 Sep 2026 12:15:07 -0300 Subject: [PATCH 1/2] change ci for rcs --- .github/scripts/check_version_bump.py | 47 ++++++++++++ .github/scripts/validate_release.py | 73 +++++++++++++++++++ .github/workflows/check-version-bump.yaml | 22 +++--- .github/workflows/release.yml | 19 ++++- docs/RELEASE.md | 38 ++++++++-- tests/ci/test_versioning_scripts.py | 88 +++++++++++++++++++++++ 6 files changed, 271 insertions(+), 16 deletions(-) create mode 100644 .github/scripts/check_version_bump.py create mode 100644 .github/scripts/validate_release.py create mode 100644 tests/ci/test_versioning_scripts.py diff --git a/.github/scripts/check_version_bump.py b/.github/scripts/check_version_bump.py new file mode 100644 index 00000000..d58f4ceb --- /dev/null +++ b/.github/scripts/check_version_bump.py @@ -0,0 +1,47 @@ +"""Check that a project version increases according to PEP 440.""" + +import argparse +import sys + +from packaging.version import InvalidVersion, Version + + +def check_version_bump(base: str, head: str) -> str: + """Require the head version to be newer than the base version.""" + try: + base_version = Version(base) + head_version = Version(head) + except InvalidVersion as error: + raise ValueError(f"Cannot compare project versions: {error}") from error + + if head_version == base_version: + raise ValueError(f"Version was not bumped (still {head_version}).") + + if head_version < base_version: + raise ValueError( + f"Version regression detected. Base is {base_version} but PR has " + f"{head_version}." + ) + + return f"Version bump OK: {base_version} -> {head_version}" + + +def main() -> int: + """Run the version comparison CLI.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("base", help="Version on the pull request base commit") + parser.add_argument("head", help="Version on the pull request head commit") + args = parser.parse_args() + + try: + message = check_version_bump(args.base, args.head) + except ValueError as error: + print(f"ERROR: {error}", file=sys.stderr) + return 1 + + print(message) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/scripts/validate_release.py b/.github/scripts/validate_release.py new file mode 100644 index 00000000..05adc47b --- /dev/null +++ b/.github/scripts/validate_release.py @@ -0,0 +1,73 @@ +"""Validate package metadata against a GitHub Release.""" + +import argparse +import sys + +from packaging.version import InvalidVersion, Version + + +def validate_release( + raw_version: str, release_tag: str, release_is_prerelease: bool +) -> str: + """Validate version normalization, tag, and pre-release status.""" + try: + version = Version(raw_version) + except InvalidVersion as error: + raise ValueError( + f"'{raw_version}' is not a valid PEP 440 version." + ) from error + + normalized_version = str(version) + if raw_version != normalized_version: + raise ValueError( + f"Version '{raw_version}' is not in its normalized PEP 440 form. " + f"Use '{normalized_version}' instead." + ) + + expected_tag = f"v{normalized_version}" + if release_tag != expected_tag: + raise ValueError( + f"GitHub Release tag '{release_tag}' does not match pyproject.toml " + f"version '{raw_version}'. Expected '{expected_tag}'." + ) + + if version.is_prerelease != release_is_prerelease: + expected_setting = "enabled" if version.is_prerelease else "disabled" + raise ValueError( + "The GitHub Release pre-release setting does not match version " + f"'{raw_version}'. Set pre-release to {expected_setting}, then rerun " + "this workflow." + ) + + return ( + f"Release metadata is valid: tag={release_tag}, " + f"pre-release={str(release_is_prerelease).lower()}" + ) + + +def main() -> int: + """Run the release validation CLI.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("version", help="Package version from pyproject.toml") + parser.add_argument("tag", help="GitHub Release tag") + parser.add_argument( + "prerelease", + choices=("true", "false"), + help="Current GitHub Release pre-release setting", + ) + args = parser.parse_args() + + try: + message = validate_release( + args.version, args.tag, args.prerelease == "true" + ) + except ValueError as error: + print(f"ERROR: {error}", file=sys.stderr) + return 1 + + print(message) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/workflows/check-version-bump.yaml b/.github/workflows/check-version-bump.yaml index 3db4af42..afad6fa0 100644 --- a/.github/workflows/check-version-bump.yaml +++ b/.github/workflows/check-version-bump.yaml @@ -18,6 +18,14 @@ jobs: with: fetch-depth: 0 + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.11" + + - name: Install version comparison dependency + run: python -m pip install packaging + - name: Check that version was bumped if src/ was modified run: | BASE_SHA="${{ github.event.pull_request.base.sha }}" @@ -29,18 +37,10 @@ jobs: BASE_VERSION=$(git show "$BASE_SHA:pyproject.toml" | grep "^version = " | cut -d'"' -f2) HEAD_VERSION=$(git show "$HEAD_SHA:pyproject.toml" | grep "^version = " | cut -d'"' -f2) - if [ "$BASE_VERSION" = "$HEAD_VERSION" ]; then - echo "ERROR: Source files under src/ were modified but the version in pyproject.toml was not bumped (still $HEAD_VERSION)." - exit 1 - fi - - HIGHER=$(printf '%s\n' "$BASE_VERSION" "$HEAD_VERSION" | sort -V | tail -1) - if [ "$HIGHER" != "$HEAD_VERSION" ]; then - echo "ERROR: Version regression detected. Base is $BASE_VERSION but PR has $HEAD_VERSION." - exit 1 - fi + # Use PEP 440 ordering because sort -V ranks RCs above final releases + python .github/scripts/check_version_bump.py \ + "$BASE_VERSION" "$HEAD_VERSION" - echo "Version bump OK: $BASE_VERSION → $HEAD_VERSION" else echo "No source file changes under src/. Version bump not required." fi diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 916b6a22..7c1cf716 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -29,13 +29,30 @@ jobs: - name: Read package name and version from pyproject.toml id: metadata run: | - pip install toml + python -m pip install toml packaging PKG_NAME=$(python -c "import toml; print(toml.load('pyproject.toml')['project']['name'])") VERSION=$(python -c "import toml; print(toml.load('pyproject.toml')['project']['version'])") echo "Publishing $PKG_NAME version $VERSION to PyPI" echo "name=$PKG_NAME" >> $GITHUB_OUTPUT echo "version=$VERSION" >> $GITHUB_OUTPUT + - name: Validate release version, tag, and pre-release status + env: + GH_TOKEN: ${{ github.token }} + PACKAGE_VERSION: ${{ steps.metadata.outputs.version }} + RELEASE_ID: ${{ github.event.release.id }} + RELEASE_TAG: ${{ github.event.release.tag_name }} + run: | + # Read the current release state so corrected settings are picked up on rerun + CURRENT_PRERELEASE=$( + gh api "repos/$GITHUB_REPOSITORY/releases/$RELEASE_ID" \ + --jq '.prerelease' + ) + export CURRENT_PRERELEASE + + python .github/scripts/validate_release.py \ + "$PACKAGE_VERSION" "$RELEASE_TAG" "$CURRENT_PRERELEASE" + - name: Check if version already exists on PyPI run: | PKG_NAME="${{ steps.metadata.outputs.name }}" diff --git a/docs/RELEASE.md b/docs/RELEASE.md index 8fd049bb..2be4c0e7 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -15,18 +15,24 @@ This guide consolidates the full release and deployment process for the Cloud SD ``` 2. Bump version + - In `pyproject.toml`: set `project.version = "X.Y.Z"` (PEP 440; no leading 'v') + - Run `uv lock` so the project version in `uv.lock` matches + - Use `X.Y.Zrc1`, `X.Y.Zrc2`, and so on for release candidates 3. Update changelog + - Use the official Changelog template 4. Commit changes + ```bash - git add pyproject.toml CHANGELOG.md + git add pyproject.toml uv.lock CHANGELOG.md git commit -m "feat: did something" ``` 5. Push and open PR, get approval and merge + ```bash git push -u origin branch-name ``` @@ -34,13 +40,22 @@ This guide consolidates the full release and deployment process for the Cloud SD - Example: `feat(): add xyz` - See: [Conventional Commits](https://www.conventionalcommits.org/) +### Release candidate cycle + +After the first release candidate (`X.Y.Zrc1`), that release line is feature-frozen. If a blocking issue requires a code change, publish and validate another release candidate (`X.Y.Zrc2`, `X.Y.Zrc3`, and so on) before the stable release. + +To promote the final release candidate, open a pull request that changes the version from `X.Y.ZrcN` to `X.Y.Z` and updates `uv.lock`. The stable release should otherwise contain the same code as the final release candidate. + ## Create and Publish GitHub Release 6. Create GitHub release (this will automatically publish to PyPI) + - Go to the repository's **Releases** page - Click **"Draft a new release"** - - Choose the tag: `vX.Y.Z` (or create new tag from main) - - Fill in the release title: `vX.Y.Z` + - Create or select the tag that exactly matches the project version with a leading `v` + - Target the merged release commit on `main` + - Fill in the release title: `vX.Y.Z - Month D, YYYY` + - For an RC, select **Set as a pre-release** and do not set it as latest - Add release notes: - Highlight key features and changes - Include breaking changes (if any) @@ -49,7 +64,8 @@ This guide consolidates the full release and deployment process for the Cloud SD - Click **"Publish release"** 7. Automated PyPI publication - - The [Publish Package to PyPI](../.github/workflows/release.yaml) workflow will automatically trigger + + - The [Publish Package to PyPI](../.github/workflows/release.yml) workflow will automatically trigger - The workflow will: - Extract version from `pyproject.toml` - Check if version already exists on PyPI (prevents duplicates) @@ -59,3 +75,17 @@ This guide consolidates the full release and deployment process for the Cloud SD - Package will be available at: `https://pypi.org/project/sap-cloud-sdk/X.Y.Z/` > **Note:** The version in `pyproject.toml` must match the release tag (without the 'v' prefix). For example, tag `vX.Y.Z` requires `version = "X.Y.Z"` in `pyproject.toml`. + +## Install and Verify + +Install a specific release candidate explicitly: + +```bash +pip install sap-cloud-sdk==1.0.0rc1 +``` + +Install the current stable release normally: + +```bash +pip install sap-cloud-sdk +``` diff --git a/tests/ci/test_versioning_scripts.py b/tests/ci/test_versioning_scripts.py new file mode 100644 index 00000000..da28ca89 --- /dev/null +++ b/tests/ci/test_versioning_scripts.py @@ -0,0 +1,88 @@ +"""Tests for CI release-version scripts.""" + +from pathlib import Path +import subprocess +import sys + +import pytest + + +REPOSITORY_ROOT = Path(__file__).parents[2] +SCRIPTS = REPOSITORY_ROOT / ".github" / "scripts" + + +@pytest.mark.parametrize( + ("version", "tag", "prerelease"), + [ + ("1.0.0rc1", "v1.0.0rc1", "true"), + ("1.0.0", "v1.0.0", "false"), + ], +) +def test_validate_release_accepts_matching_metadata( + version: str, tag: str, prerelease: str +): + result = subprocess.run( + [sys.executable, SCRIPTS / "validate_release.py", version, tag, prerelease], + capture_output=True, + check=False, + text=True, + ) + + assert result.returncode == 0, result.stderr + + +@pytest.mark.parametrize( + ("version", "tag", "prerelease"), + [ + ("1.0.0rc1", "v1.0.0rc1", "false"), + ("1.0.0-rc.1", "v1.0.0-rc.1", "true"), + ("1.0.0", "v1.0.1", "false"), + ], +) +def test_validate_release_rejects_mismatched_metadata( + version: str, tag: str, prerelease: str +): + result = subprocess.run( + [sys.executable, SCRIPTS / "validate_release.py", version, tag, prerelease], + capture_output=True, + check=False, + text=True, + ) + + assert result.returncode == 1 + assert "ERROR:" in result.stderr + + +@pytest.mark.parametrize( + ("base", "head"), + [ + ("0.49.1", "1.0.0rc1"), + ("1.0.0rc1", "1.0.0rc2"), + ("1.0.0rc2", "1.0.0"), + ], +) +def test_check_version_bump_accepts_newer_versions(base: str, head: str): + result = subprocess.run( + [sys.executable, SCRIPTS / "check_version_bump.py", base, head], + capture_output=True, + check=False, + text=True, + ) + + assert result.returncode == 0, result.stderr + + +@pytest.mark.parametrize( + ("base", "head"), + [("1.0.0", "1.0.0rc1"), ("1.0.0", "1.0.0")], +) +def test_check_version_bump_rejects_non_increasing_versions(base: str, head: str): + result = subprocess.run( + [sys.executable, SCRIPTS / "check_version_bump.py", base, head], + capture_output=True, + check=False, + text=True, + ) + + assert result.returncode == 1 + assert "ERROR:" in result.stderr From 92fd477a64de3ecc5c41e934c50d58edf9c9edbe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Pedro=20Brun?= Date: Fri, 4 Sep 2026 15:24:49 -0300 Subject: [PATCH 2/2] remove changelog mentions --- docs/RELEASE.md | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/docs/RELEASE.md b/docs/RELEASE.md index 2be4c0e7..0643b6a9 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -20,18 +20,14 @@ This guide consolidates the full release and deployment process for the Cloud SD - Run `uv lock` so the project version in `uv.lock` matches - Use `X.Y.Zrc1`, `X.Y.Zrc2`, and so on for release candidates -3. Update changelog - - - Use the official Changelog template - -4. Commit changes +3. Commit changes ```bash - git add pyproject.toml uv.lock CHANGELOG.md + git add pyproject.toml uv.lock git commit -m "feat: did something" ``` -5. Push and open PR, get approval and merge +4. Push and open PR, get approval and merge ```bash git push -u origin branch-name @@ -48,7 +44,7 @@ To promote the final release candidate, open a pull request that changes the ver ## Create and Publish GitHub Release -6. Create GitHub release (this will automatically publish to PyPI) +5. Create GitHub release (this will automatically publish to PyPI) - Go to the repository's **Releases** page - Click **"Draft a new release"** @@ -60,10 +56,9 @@ To promote the final release candidate, open a pull request that changes the ver - Highlight key features and changes - Include breaking changes (if any) - Reference relevant issues/PRs - - Use the changelog as reference - Click **"Publish release"** -7. Automated PyPI publication +6. Automated PyPI publication - The [Publish Package to PyPI](../.github/workflows/release.yml) workflow will automatically trigger - The workflow will: