diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c48fb42 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,19 @@ +name: CI + +on: + push: + branches: [ main ] + tags-ignore: [ '**' ] + pull_request: + branches: [ main ] + +permissions: + contents: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + py-checks: + uses: chatmail/workflows/.github/workflows/py-checks.yml@main diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..2703fd4 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,34 @@ +name: Release to PyPI + +on: + push: + tags: + - "v*" + +permissions: + contents: read + +jobs: + # Lint and build via the shared reusable workflow, which uploads + # the built distributions as a "dist" artifact. + py-checks: + uses: chatmail/workflows/.github/workflows/py-checks.yml@main + + # The publish job must stay here in the repo's own workflow: PyPI + # trusted publishing rejects publishes from reusable workflows. + # See https://github.com/pypi/warehouse/issues/11096 + publish: + needs: py-checks + runs-on: ubuntu-latest + environment: pypi + permissions: + id-token: write + steps: + - uses: actions/download-artifact@v8 + with: + name: dist + path: dist/ + + - uses: pypa/gh-action-pypi-publish@release/v1 + with: + skip-existing: true diff --git a/CHANGELOG.md b/CHANGELOG.md index eaabb20..42fcde5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ (in development) +- adopt shared CI from chatmail/workflows (ruff lint+format, build) + and release via PyPI trusted publishing on v* tags; release.py is + replaced by tagging vX.Y.Z + ## 0.17.0 ### Features diff --git a/README.md b/README.md index e8e249f..44b8d08 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ To install use: - pip install cmping + uv tool install cmping To send and receive from a single chatmail relay: @@ -61,23 +61,21 @@ Example output for two-domain ping: ## Developing / Releasing cmping -1. clone the git repository at https://github.com/chatmail/cmping +1. clone the git repository at https://github.com/chatmail/cmping -2. install 'cmping" in editing mode: `pip install -e .` +2. install 'cmping' in editing mode: `uv pip install -e .` 3. edit cmping.py and test, finally commit your changes -4. update CHANGELOG.md with the new version number and changes +CI checks (ruff lint/format, packaging) come from the shared +[chatmail/workflows](https://github.com/chatmail/workflows) +repository; run them locally with `uvx ruff check .` and +`uvx ruff format --check .` -5. install build/release tools: `pip install build twine` +To release, update CHANGELOG.md, then create and push a version tag: -6. run the release script: + git tag -a v0.18.0 -m "Release v0.18.0" + git push origin main v0.18.0 - python release.py - - The release script will: - - Validate the version in CHANGELOG.md is a proper version jump - - Create and push a git tag for the version - - Build the package and upload to PyPI - - Add a dev changelog entry and commit it - - Print which tag was uploaded to PyPI +The release.yml workflow then builds and publishes to PyPI via +trusted publishing (OIDC); no local twine or PyPI token is involved. diff --git a/cmping.py b/cmping.py index 8516cb0..603039b 100644 --- a/cmping.py +++ b/cmping.py @@ -299,7 +299,9 @@ def setup_accounts(args, sender_maker, receiver_maker): try: sender = sender_maker.get_relay_account(args.relay1) profiles_created += 1 - print_progress("Setting up profiles", profiles_created, total_profiles, profiles_created) + print_progress( + "Setting up profiles", profiles_created, total_profiles, profiles_created + ) except Exception as e: print(f"\r✗ Failed to setup sender profile on {args.relay1}: {e}") sys.exit(1) @@ -311,9 +313,14 @@ def setup_accounts(args, sender_maker, receiver_maker): receiver = receiver_maker.get_relay_account(args.relay2) receivers.append(receiver) profiles_created += 1 - print_progress("Setting up profiles", profiles_created, total_profiles, profiles_created) + print_progress( + "Setting up profiles", + profiles_created, + total_profiles, + profiles_created, + ) except Exception as e: - print(f"\r✗ Failed to setup receiver profile {i+1} on {args.relay2}: {e}") + print(f"\r✗ Failed to setup receiver profile {i + 1} on {args.relay2}: {e}") sys.exit(1) # Profile setup complete @@ -420,7 +427,9 @@ def wait_online_thread(maker): t.join() if online_errors: - print(f"\n✗ Timeout or error waiting for profiles to be online: {online_errors[0]}") + print( + f"\n✗ Timeout or error waiting for profiles to be online: {online_errors[0]}" + ) sys.exit(1) print_progress("Waiting for profiles to be online", done=True) @@ -437,11 +446,11 @@ def perform_ping(args): Pinger: The pinger object with results """ base_accounts_dir = xdg_cache_home().joinpath("cmping") - + # Determine unique relays being tested. Using a set to deduplicate when # relay1 == relay2 (same relay testing), so we only create one RPC context. relays = {args.relay1, args.relay2} - + # Handle --reset option: remove account directories for tested relays if args.reset: for relay in relays: @@ -449,7 +458,7 @@ def perform_ping(args): if relay_dir.exists(): print(f"# Removing account directory for {relay}: {relay_dir}") shutil.rmtree(relay_dir) - + # Create per-relay account directories and RPC instances. relay_contexts = {} # {relay: RelayContext} @@ -633,7 +642,9 @@ def __init__(self, args, sender, group, receivers): @property def loss(self): expected_total = self.sent * len(self.receivers) - return 0.0 if expected_total == 0 else (1 - self.received / expected_total) * 100 + return ( + 0.0 if expected_total == 0 else (1 - self.received / expected_total) * 100 + ) def send_pings(self): """Send ping messages to the group at regular intervals. diff --git a/pyproject.toml b/pyproject.toml index d631dbb..b1980ba 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,6 +6,7 @@ build-backend = "setuptools.build_meta" name = "cmping" readme = "README.md" description = "ping messages between chatmail relays" +requires-python = ">=3.11" dependencies = [ "deltachat-rpc-server>=2.24.0", "deltachat-rpc-client>=2.24.0", @@ -23,17 +24,15 @@ py-modules = ["cmping"] [project.urls] Repository = "https://github.com/chatmail/cmping" -Changelog = "https://github.com/chatmail/cmping/blob/master/CHANGELOG.md" +Changelog = "https://github.com/chatmail/cmping/blob/main/CHANGELOG.md" [project.scripts] cmping = "cmping:main" [tool.pytest.ini_options] -addopts = "-v -ra --strict-markers" - -[tool.setuptools.packages.find] -where = ["."] - +# The deltachat_rpc_client pytest plugin is not used and would need +# extra dependencies (execnet) to even load. +addopts = "-v -ra --strict-markers -p no:deltachat_rpc_client.pytestplugin" [tool.ruff] lint.select = [ diff --git a/release.py b/release.py deleted file mode 100755 index 34d3450..0000000 --- a/release.py +++ /dev/null @@ -1,219 +0,0 @@ -#!/usr/bin/env python3 -""" -Release script for cmping. - -This script: -1. Parses the CHANGELOG.md to get the latest version -2. Validates it's a proper version jump from the current git tag -3. Creates a git tag for the version -4. Builds and uploads to PyPI -5. On success, adds a "dev" changelog entry and commits it -""" - -import re -import subprocess -import sys -from pathlib import Path - - -def run(cmd, check=True, capture=True): - """Run a shell command and return output.""" - print(f"$ {cmd}") - result = subprocess.run( - cmd, shell=True, check=check, capture_output=capture, text=True - ) - if capture and result.stdout: - print(result.stdout.strip()) - return result - - -def get_changelog_version(): - """Parse CHANGELOG.md to get the latest version.""" - changelog = Path("CHANGELOG.md").read_text() - # Match version patterns like "## 0.16.0" or "## 1.0.0" - match = re.search(r"^## (\d+\.\d+\.\d+)", changelog, re.MULTILINE) - if not match: - print("ERROR: Could not find version in CHANGELOG.md") - print("Expected format: '## X.Y.Z' at the start of a line") - sys.exit(1) - return match.group(1) - - -def get_latest_git_tag(): - """Get the latest git tag version.""" - result = run("git tag --sort=-v:refname", check=False) - if result.returncode != 0 or not result.stdout.strip(): - return None - # Get the first tag (latest by version) - tags = result.stdout.strip().split("\n") - for tag in tags: - # Match version tags like "0.15.0" or "v0.15.0" - if re.match(r"^v?\d+\.\d+\.\d+$", tag): - return tag.lstrip("v") - return None - - -def parse_version(version_str): - """Parse version string into tuple of ints.""" - return tuple(int(x) for x in version_str.split(".")) - - -def validate_version_jump(new_version, old_version): - """Validate that new_version is a proper increment from old_version.""" - if old_version is None: - print(f"No previous version found, {new_version} will be the first release") - return True - - new = parse_version(new_version) - old = parse_version(old_version) - - # Check for proper version jump (major, minor, or patch increment) - if new <= old: - print(f"ERROR: New version {new_version} is not greater than {old_version}") - return False - - # Check that it's a single increment (not skipping versions) - major_diff = new[0] - old[0] - minor_diff = new[1] - old[1] - patch_diff = new[2] - old[2] - - valid = False - if major_diff == 1 and new[1] == 0 and new[2] == 0: - # Major version bump: X.0.0 - valid = True - elif major_diff == 0 and minor_diff == 1 and new[2] == 0: - # Minor version bump: X.Y.0 - valid = True - elif major_diff == 0 and minor_diff == 0 and patch_diff == 1: - # Patch version bump: X.Y.Z - valid = True - - if not valid: - print(f"ERROR: Invalid version jump from {old_version} to {new_version}") - print("Expected one of:") - print(f" - Major: {old[0]+1}.0.0") - print(f" - Minor: {old[0]}.{old[1]+1}.0") - print(f" - Patch: {old[0]}.{old[1]}.{old[2]+1}") - return False - - return True - - -def create_git_tag(version): - """Create and push a git tag for the version.""" - tag = version - print(f"\nCreating git tag: {tag}") - - # Check if tag already exists - result = run(f"git tag -l {tag}", check=False) - if result.stdout.strip() == tag: - print(f"ERROR: Tag {tag} already exists") - sys.exit(1) - - # Create the tag - run(f"git tag {tag}") - - # Push the tag - print(f"Pushing tag {tag} to origin...") - run(f"git push origin {tag}") - - return tag - - -def build_and_upload(): - """Build the package and upload to PyPI.""" - print("\nCleaning previous builds...") - run("rm -rf dist build *.egg-info") - - print("\nBuilding package...") - run("python -m build") - - print("\nUploading to PyPI...") - # Upload all distribution files (wheel and sdist) - run("twine upload dist/*") - - -def add_dev_changelog_entry(released_version): - """Add a dev changelog entry after successful release.""" - changelog_path = Path("CHANGELOG.md") - changelog = changelog_path.read_text() - - # Parse the version to create the dev version - parts = parse_version(released_version) - # Bump patch version for dev - dev_version = f"{parts[0]}.{parts[1]}.{parts[2] + 1}.dev0" - - # Insert new dev entry after the header - new_entry = f""" -# cmping changelog - -## {dev_version} - -(in development) - -## {released_version}""" - - # Replace the header and current version - changelog = re.sub( - r"^\s*# cmping changelog\s*\n\s*## " + re.escape(released_version), - new_entry, - changelog, - flags=re.MULTILINE, - ) - - changelog_path.write_text(changelog) - print(f"\nAdded dev changelog entry: {dev_version}") - - # Commit the change - run("git add CHANGELOG.md") - run(f'git commit -m "post release: start {dev_version}"') - run("git push") - - -def main(): - """Main release workflow.""" - print("=" * 60) - print("cmping Release Script") - print("=" * 60) - - # Step 1: Get version from changelog - print("\n[1/5] Reading version from CHANGELOG.md...") - new_version = get_changelog_version() - print(f"Found version: {new_version}") - - # Step 2: Get current git tag - print("\n[2/5] Checking current git tags...") - old_version = get_latest_git_tag() - if old_version: - print(f"Latest git tag: {old_version}") - else: - print("No previous version tags found") - - # Step 3: Validate version jump - print("\n[3/5] Validating version jump...") - if not validate_version_jump(new_version, old_version): - sys.exit(1) - print(f"Version jump {old_version or 'none'} -> {new_version} is valid") - - # Step 4: Create git tag and build/upload - print("\n[4/5] Creating tag and uploading to PyPI...") - tag = create_git_tag(new_version) - build_and_upload() - - # Step 5: Add dev changelog entry - print("\n[5/5] Adding dev changelog entry...") - add_dev_changelog_entry(new_version) - - # Final summary - print("\n" + "=" * 60) - print("Release completed successfully!") - print("=" * 60) - print(f"\nUploaded to PyPI: cmping {tag}") - print(f"Tag: {tag}") - print("\nNext steps:") - print(" - Verify the release on PyPI: https://pypi.org/project/cmping/") - print(f" - Check the tag on GitHub: https://github.com/chatmail/cmping/releases/tag/{tag}") - - -if __name__ == "__main__": - main()