diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..0a6ba4e --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,57 @@ +name: Release + +on: + workflow_dispatch: + inputs: + action: + description: "Release action to perform" + required: true + type: choice + options: + - major + - minor + - patch + - set-version + version: + description: "Version to set (required only for set-version)" + required: false + +jobs: + release: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install dependencies + run: pip install semver + + - name: Run release script + run: | + if [ "${{ inputs.action }}" = "set-version" ]; then + if [ -z "${{ inputs.version }}" ]; then + echo "Version is required for set-version" + exit 1 + fi + python tools/spec_release.py set=${{ inputs.version }} + else + python tools/spec_release.py ${{ inputs.action }} + fi + + - name: Commit version updates + run: | + git config user.name "github-actions" + git config user.email "github-actions@github.com" + git add . + git commit -m "chore(release): bump version (${{ inputs.action }})" || echo "No changes to commit" + + - name: Push changes + run: git push diff --git a/.github/workflows/release_tests.yml b/.github/workflows/release_tests.yml new file mode 100644 index 0000000..1526f50 --- /dev/null +++ b/.github/workflows/release_tests.yml @@ -0,0 +1,28 @@ +name: Test Python Release + +on: + workflow_dispatch: + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install pytest semver + + - name: Run tests + run: | + PYTHONPATH=$PYTHONPATH:. pytest -v tests/ diff --git a/tests/test_release.py b/tests/test_release.py new file mode 100644 index 0000000..7b062ed --- /dev/null +++ b/tests/test_release.py @@ -0,0 +1,41 @@ +import pytest +from semver import VersionInfo +from tools.spec_release import compute_next_version + +# start release + +def test_start_minor_release_from_stable(): + v = VersionInfo.parse("1.2.3") + new = compute_next_version(v, "minor") + assert new.major == 1 + assert new.minor == 3 # bump minor + assert new.patch == 0 + +def test_start_major_release_from_stable(): + v = VersionInfo.parse("1.2.3") + new = compute_next_version(v, "major") + assert new.major == 2 # bump major + assert new.minor == 0 + assert new.patch == 0 + +def test_start_release_invalid_action(): + v = VersionInfo.parse("1.2.3") + with pytest.raises(SystemExit): + compute_next_version(v, "invalid-action") + +# patch release + +def test_patch_release_from_stable(): + v = VersionInfo.parse("1.2.3") + new = compute_next_version(v, "patch") + assert str(new) == "1.2.4" + +# set version + +def test_set_version_valid(): + v = compute_next_version(VersionInfo.parse("0.0.0"), "set", "2.0.1") + assert str(v) == "2.0.1" + +def test_set_version_invalid(): + with pytest.raises(SystemExit): + compute_next_version(VersionInfo.parse("0.0.0"), "set", "not-a-version") diff --git a/tools/__init__.py b/tools/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tools/spec_release.py b/tools/spec_release.py new file mode 100644 index 0000000..df68ec7 --- /dev/null +++ b/tools/spec_release.py @@ -0,0 +1,128 @@ +#!/usr/bin/env python3 + +import sys +from pathlib import Path +from semver import VersionInfo + +VERSION_FILE = Path("version.txt") + +SCHEMA_DIRS = ["schemas", "custom"] +CONFORMANCE_DIRS = ["conformance", "custom"] +DOC_FILES = ["cloudevents-binding.md", "spec.md", "links.md"] +README_FILE = "README.md" + +# Utilities + +def read_version() -> VersionInfo: + if not VERSION_FILE.exists(): + sys.exit(f"{VERSION_FILE} not found") + try: + return VersionInfo.parse(VERSION_FILE.read_text().strip()) + except ValueError as e: + sys.exit(f"Invalid version: {e}") + +def write_version(version: VersionInfo): + VERSION_FILE.write_text(f"{version}\n") + +def replace_all(files, old: str, new: str): + for f in files: + p = Path(f) + if p.exists(): + p.write_text(p.read_text().replace(old, new)) + +def find_files(dirs, suffix=".json"): + return [ + f for d in dirs if Path(d).exists() + for f in Path(d).rglob(f"*{suffix}") + ] + +# Version transitions + +def compute_next_version(old: VersionInfo, action: str, value=None) -> VersionInfo: + """ + Compute next version based on the action: + - major: bump major, reset minor & patch + - minor: bump minor, reset patch + - patch: bump patch + - set: set to exact version + """ + if action == "set": + try: + return VersionInfo.parse(value) + except ValueError as e: + sys.exit(f"Invalid version: {e}") + + if action == "major": + return old.bump_major() + if action == "minor": + return old.bump_minor() + if action == "patch": + return old.bump_patch() + + sys.exit(f"Unknown action: {action}") + +# Repository updates + +def update_repository(old: VersionInfo, new: VersionInfo): + old_v, new_v = str(old), str(new) + + # Update schema references + replace_all( + find_files(SCHEMA_DIRS), + f"https://cdevents.dev/{old_v}/schema/", + f"https://cdevents.dev/{new_v}/schema/", + ) + + # Update conformance files + replace_all( + find_files(CONFORMANCE_DIRS), + f'"version": "{old_v}"', + f'"version": "{new_v}"', + ) + + # Update documentation files + replace_all( + DOC_FILES, + f'"version": "{old_v}"', + f'"version": "{new_v}"', + ) + + # Update README + replace_all([README_FILE], f"v{old_v}", f"v{new_v}") + +# CLI + +def parse_action(): + """ + Get release action from command-line arguments. + Usage: + python release.py major + python release.py minor + python release.py patch + python release.py set=X.Y.Z + """ + if len(sys.argv) < 2: + sys.exit("Usage: release.py ") + + arg = sys.argv[1].lower() + + if arg in ("major", "minor", "patch"): + return arg, None + + if arg.startswith("set="): + return "set", arg.split("=", 1)[1] + + sys.exit(f"Unknown action: {arg}") + +# Main + +def main(): + action, value = parse_action() + old = read_version() + new = compute_next_version(old, action, value) + update_repository(old, new) + write_version(new) + print(f"Version updated: {old} -> {new}") + +if __name__ == "__main__": + main()