Skip to content
Open
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
57 changes: 57 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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
28 changes: 28 additions & 0 deletions .github/workflows/release_tests.yml
Original file line number Diff line number Diff line change
@@ -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/
41 changes: 41 additions & 0 deletions tests/test_release.py
Original file line number Diff line number Diff line change
@@ -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")
Empty file added tools/__init__.py
Empty file.
128 changes: 128 additions & 0 deletions tools/spec_release.py
Original file line number Diff line number Diff line change
@@ -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 <major|minor|patch|set=X.Y.Z>")

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()