Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions .github/scripts/check_version_bump.py
Original file line number Diff line number Diff line change
@@ -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())
73 changes: 73 additions & 0 deletions .github/scripts/validate_release.py
Original file line number Diff line number Diff line change
@@ -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())
22 changes: 11 additions & 11 deletions .github/workflows/check-version-bump.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}"
Expand All @@ -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
19 changes: 18 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}"
Expand Down
47 changes: 36 additions & 11 deletions docs/RELEASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,41 +15,52 @@ 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
3. Commit changes

4. Commit changes
```bash
git add pyproject.toml 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
```
- Merge commit message should follow Conventional Commits
- 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)
5. 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)
- Reference relevant issues/PRs
- Use the changelog as reference
- Click **"Publish release"**

7. Automated PyPI publication
- The [Publish Package to PyPI](../.github/workflows/release.yaml) workflow will automatically trigger
6. Automated PyPI publication

- 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)
Expand All @@ -59,3 +70,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
```
88 changes: 88 additions & 0 deletions tests/ci/test_versioning_scripts.py
Original file line number Diff line number Diff line change
@@ -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
Loading