From c4d76acbc427780691d04d069dfb86fe5fa444d2 Mon Sep 17 00:00:00 2001 From: ParticularlyPythonicBS Date: Tue, 6 Jan 2026 13:20:03 -0500 Subject: [PATCH 1/3] infra: pypi trusted publishing ci --- .github/workflows/publish.yml | 32 ++++++++++++++++++++++++++++++++ temoa/__about__.py | 2 +- tests/smoke_test.py | 31 +++++++++++++++++++++++++++++++ 3 files changed, 64 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/publish.yml create mode 100644 tests/smoke_test.py diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 00000000..94cf9875 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,32 @@ +name: "Publish" + +on: + push: + tags: + # Publish on any tag starting with a `v`, e.g., v0.1.0 + - v* + +jobs: + run: + runs-on: ubuntu-latest + environment: + name: pypi + permissions: + id-token: write + contents: read + steps: + - name: Checkout + uses: actions/checkout@v6 + - name: Install uv + uses: astral-sh/setup-uv@v7 + - name: Install Python 3.12 + run: uv python install 3.12 + - name: Build + run: uv build + # Check that basic features work and we didn't miss to include crucial files + - name: Smoke test (wheel) + run: uv run --isolated --no-project --with dist/*.whl tests/smoke_test.py + - name: Smoke test (source distribution) + run: uv run --isolated --no-project --with dist/*.tar.gz tests/smoke_test.py + - name: Publish + run: uv publish diff --git a/temoa/__about__.py b/temoa/__about__.py index a8b1a2f0..7afb01ef 100644 --- a/temoa/__about__.py +++ b/temoa/__about__.py @@ -1,6 +1,6 @@ import re -__version__ = '4.0.0a1.dev20251201' +__version__ = '4.0.0a1' # Parse the version string to get major and minor versions # We use a regex to be robust against versions like "4.1a1" or "4.0.0.dev1" diff --git a/tests/smoke_test.py b/tests/smoke_test.py new file mode 100644 index 00000000..d1b1e6a0 --- /dev/null +++ b/tests/smoke_test.py @@ -0,0 +1,31 @@ +import subprocess +import sys +import temoa + +def test_import() -> None: + print(f"Importing temoa version: {temoa.__version__}") + assert temoa.__version__ is not None + +def test_cli() -> None: + print("Running temoa --version CLI command...") + result = subprocess.run(["temoa", "--version"], capture_output=True, text=True) + print(f"CLI output: {result.stdout.strip()}") + assert result.returncode == 0 + assert "Temoa Version:" in result.stdout + assert temoa.__version__ in result.stdout + +def test_help() -> None: + print("Running temoa --help CLI command...") + result = subprocess.run(["temoa", "--help"], capture_output=True, text=True) + assert result.returncode == 0 + assert "The Temoa Project" in result.stdout + +if __name__ == "__main__": + try: + test_import() + test_cli() + test_help() + print("\n✅ Smoke test passed!") + except Exception as e: + print(f"\n❌ Smoke test failed: {e}") + sys.exit(1) From ed9bd27b1a2f04d93dffd38308ec2da3eb83df29 Mon Sep 17 00:00:00 2001 From: ParticularlyPythonicBS Date: Thu, 9 Apr 2026 13:16:17 -0400 Subject: [PATCH 2/3] ci: secure and enhance publish workflow --- .github/workflows/publish.yml | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 94cf9875..f68ea3a8 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -16,17 +16,28 @@ jobs: contents: read steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Install uv - uses: astral-sh/setup-uv@v7 + uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0 - name: Install Python 3.12 run: uv python install 3.12 - name: Build run: uv build + - name: Verify tag matches version + run: | + VERSION=$(grep "__version__ =" temoa/__about__.py | cut -d "'" -f 2) + TAG=${GITHUB_REF#refs/tags/v} + if [ "$VERSION" != "$TAG" ]; then + echo "Error: Tag v$TAG does not match version $VERSION in temoa/__about__.py" + exit 1 + fi + echo "Tag v$TAG matches version $VERSION" # Check that basic features work and we didn't miss to include crucial files - name: Smoke test (wheel) + timeout-minutes: 10 run: uv run --isolated --no-project --with dist/*.whl tests/smoke_test.py - name: Smoke test (source distribution) + timeout-minutes: 10 run: uv run --isolated --no-project --with dist/*.tar.gz tests/smoke_test.py - name: Publish run: uv publish From 5c24fa10f1d3e7cdf2c3eb52c117d45d6cb06e21 Mon Sep 17 00:00:00 2001 From: ParticularlyPythonicBS Date: Thu, 9 Apr 2026 13:16:48 -0400 Subject: [PATCH 3/3] dev: add version bumping utility and robust smoke tests --- .github/workflows/publish.yml | 2 +- .gitignore | 1 + scripts/bump_version.py | 150 ++++++++++++++++++++++++++++++++++ temoa/__about__.py | 2 +- tests/smoke_test.py | 56 ++++++++----- 5 files changed, 188 insertions(+), 23 deletions(-) create mode 100755 scripts/bump_version.py diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index f68ea3a8..59437854 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -25,7 +25,7 @@ jobs: run: uv build - name: Verify tag matches version run: | - VERSION=$(grep "__version__ =" temoa/__about__.py | cut -d "'" -f 2) + VERSION=$(python3 -c "import ast; d = {}; content = open('temoa/__about__.py').read(); exec(compile(ast.parse(content), 'temoa/__about__.py', 'exec'), d); print(d['__version__'])") TAG=${GITHUB_REF#refs/tags/v} if [ "$VERSION" != "$TAG" ]; then echo "Error: Tag v$TAG does not match version $VERSION in temoa/__about__.py" diff --git a/.gitignore b/.gitignore index b63c733a..f3a30a2e 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ !output_files/ !.github/ !stubs/ +!scripts/ # unignore files !.gitignore diff --git a/scripts/bump_version.py b/scripts/bump_version.py new file mode 100755 index 00000000..bec70c19 --- /dev/null +++ b/scripts/bump_version.py @@ -0,0 +1,150 @@ +#!/usr/bin/env python3 +import argparse +import re +import sys +from pathlib import Path +from typing import Any + + +def parse_version(version_str: str) -> dict[str, Any]: + """ + Parses a PEP 440 version string into its components. + Basic supported format: major.minor.patch[a|b|rcN] + """ + pattern = r'^(\d+)\.(\d+)\.(\d+)(?:([abc]|rc)(\d+))?$' + match = re.match(pattern, version_str) + if not match: + raise ValueError(f"Version '{version_str}' does not match expected format X.Y.Z[a|b|rcN]") + + major, minor, patch, pre_type, pre_num = match.groups() + return { + 'major': int(major), + 'minor': int(minor), + 'patch': int(patch), + 'pre_type': pre_type, + 'pre_num': int(pre_num) if pre_num else None, + } + + +def stringify_version(components: dict[str, Any]) -> str: + base = f'{components["major"]}.{components["minor"]}.{components["patch"]}' + if components['pre_type']: + return f'{base}{components["pre_type"]}{components["pre_num"] or 1}' + return base + + +def bump_version(components: dict[str, Any], part: str) -> dict[str, Any]: + new = components.copy() + + if part == 'major': + new['major'] += 1 + new['minor'] = 0 + new['patch'] = 0 + new['pre_type'] = None + new['pre_num'] = None + elif part == 'minor': + new['minor'] += 1 + new['patch'] = 0 + new['pre_type'] = None + new['pre_num'] = None + elif part == 'patch': + new['patch'] += 1 + new['pre_type'] = None + new['pre_num'] = None + elif part in ['a', 'b', 'rc', 'alpha', 'beta']: + pre_map = {'alpha': 'a', 'beta': 'b', 'rc': 'rc', 'a': 'a', 'b': 'b'} + target_pre = pre_map[part] + pre_ordinal = {'a': 0, 'b': 1, 'rc': 2} + + # Check for precedence to prevent downgrading within the same patch version + if new['pre_type'] and pre_ordinal[target_pre] < pre_ordinal[new['pre_type']]: + new['patch'] += 1 + new['pre_type'] = target_pre + new['pre_num'] = 1 + elif new['pre_type'] == target_pre: + new['pre_num'] = (new['pre_num'] or 0) + 1 + else: + # If moving to higher precedence (e.g., a -> b) or starting from final + if not new['pre_type']: + new['patch'] += 1 + new['pre_type'] = target_pre + new['pre_num'] = 1 + elif part == 'final': + new['pre_type'] = None + new['pre_num'] = None + else: + raise ValueError(f'Unknown part to bump: {part}') + + return new + + +def main() -> None: + parser = argparse.ArgumentParser(description='Bump Temoa version in temoa/__about__.py') + parser.add_argument( + 'part', + choices=['major', 'minor', 'patch', 'alpha', 'beta', 'rc', 'final', 'a', 'b'], + help='The part of the version to bump', + ) + parser.add_argument('--dry-run', action='store_true', help="Don't write to file") + + args = parser.parse_args() + + about_path = Path('temoa/__about__.py') + if not about_path.exists(): + print(f'Error: {about_path} not found.') + sys.exit(1) + + content = about_path.read_text() + version_match = re.search(r"__version__ = ['\"]([^'\"]+)['\"]", content) + if not version_match: + print('Error: Could not find __version__ in temoa/__about__.py') + sys.exit(1) + + old_version_str = version_match.group(1) + try: + components = parse_version(old_version_str) + except ValueError as e: + print(f'Error: {e}') + sys.exit(1) + + new_components = bump_version(components, args.part) + new_version_str = stringify_version(new_components) + + if old_version_str == new_version_str: + print(f'Version is already {new_version_str}. No change made.') + return + + print(f'Bumping version: {old_version_str} -> {new_version_str}') + + if not args.dry_run: + new_content = content.replace( + f"__version__ = '{old_version_str}'", f"__version__ = '{new_version_str}'" + ) + new_content = new_content.replace( + f'__version__ = "{old_version_str}"', f'__version__ = "{new_version_str}"' + ) + + # Also update TEMOA_MAJOR and TEMOA_MINOR if they changed + if new_components['major'] != components['major']: + new_content = re.sub( + r'TEMOA_MAJOR = \d+', f'TEMOA_MAJOR = {new_components["major"]}', new_content + ) + if new_components['minor'] != components['minor']: + new_content = re.sub( + r'TEMOA_MINOR = \d+', f'TEMOA_MINOR = {new_components["minor"]}', new_content + ) + + about_path.write_text(new_content) + print(f'Successfully updated {about_path}') + + print('\nNext steps:') + print(f' git add {about_path}') + print(f' git commit -m "chore: bump version to {new_version_str}"') + print(f' git tag -a v{new_version_str} -m "Release v{new_version_str}"') + print(f' git push origin v{new_version_str}') + else: + print('Dry run: file not modified.') + + +if __name__ == '__main__': + main() diff --git a/temoa/__about__.py b/temoa/__about__.py index 7afb01ef..28c809a0 100644 --- a/temoa/__about__.py +++ b/temoa/__about__.py @@ -1,6 +1,6 @@ import re -__version__ = '4.0.0a1' +__version__ = '4.0.0a2' # Parse the version string to get major and minor versions # We use a regex to be robust against versions like "4.1a1" or "4.0.0.dev1" diff --git a/tests/smoke_test.py b/tests/smoke_test.py index d1b1e6a0..b7fd137c 100644 --- a/tests/smoke_test.py +++ b/tests/smoke_test.py @@ -1,31 +1,45 @@ +import shutil import subprocess -import sys + import temoa + +def _find_temoa_path() -> str: + path = shutil.which('temoa') + if not path: + raise RuntimeError('temoa executable not found in PATH') + return path + + def test_import() -> None: - print(f"Importing temoa version: {temoa.__version__}") + print(f'Importing temoa version: {temoa.__version__}') assert temoa.__version__ is not None + def test_cli() -> None: - print("Running temoa --version CLI command...") - result = subprocess.run(["temoa", "--version"], capture_output=True, text=True) - print(f"CLI output: {result.stdout.strip()}") - assert result.returncode == 0 - assert "Temoa Version:" in result.stdout + print('Running temoa --version CLI command...') + temoa_path = _find_temoa_path() + + result = subprocess.run( + [temoa_path, '--version'], capture_output=True, text=True, timeout=10, check=True + ) + print(f'CLI output: {result.stdout.strip()}') + assert 'Temoa Version:' in result.stdout assert temoa.__version__ in result.stdout + def test_help() -> None: - print("Running temoa --help CLI command...") - result = subprocess.run(["temoa", "--help"], capture_output=True, text=True) - assert result.returncode == 0 - assert "The Temoa Project" in result.stdout - -if __name__ == "__main__": - try: - test_import() - test_cli() - test_help() - print("\n✅ Smoke test passed!") - except Exception as e: - print(f"\n❌ Smoke test failed: {e}") - sys.exit(1) + print('Running temoa --help CLI command...') + temoa_path = _find_temoa_path() + + result = subprocess.run( + [temoa_path, '--help'], capture_output=True, text=True, timeout=10, check=True + ) + assert 'The Temoa Project' in result.stdout + + +if __name__ == '__main__': + test_import() + test_cli() + test_help() + print('\n✅ Smoke test passed!')