diff --git a/.github/workflows/craftgate-build.yml b/.github/workflows/craftgate-build.yml new file mode 100644 index 0000000..5947688 --- /dev/null +++ b/.github/workflows/craftgate-build.yml @@ -0,0 +1,50 @@ +name: Craftgate Python CI + +on: [ pull_request ] + +jobs: + checks: + runs-on: ubuntu-22.04 + strategy: + fail-fast: false + matrix: + python-version: + - "3.7" + - "3.8" + - "3.9" + - "3.10" + - "3.11" + - "3.12" + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v6 + with: + python-version: ${{ matrix.python-version }} + cache: 'pip' + + - name: Upgrade packaging tooling + run: python -m pip install --upgrade pip setuptools wheel + + - name: Install project + run: pip install -e . + + - name: Verify package structure + run: python scripts/check_init_files.py + + - name: Compile sources + run: python -m compileall craftgate + + - name: Build distribution and verify metadata + if: matrix.python-version == '3.12' + run: | + python -m pip install build twine + rm -rf dist build + python -m build + twine check dist/* + + - name: Skip integration tests + run: echo "Integration tests rely on live credentials and are intentionally skipped in CI." diff --git a/.github/workflows/craftgate-release.yml b/.github/workflows/craftgate-release.yml new file mode 100644 index 0000000..8c8a37d --- /dev/null +++ b/.github/workflows/craftgate-release.yml @@ -0,0 +1,95 @@ +name: Craftgate Python Release + +on: + workflow_dispatch: + inputs: + version: + description: "Release version (format: 1.2.3)" + required: true + release_notes: + description: "Optional release notes (markdown)." + required: false + default: "" + publish_to_pypi: + description: "Upload build artifacts to PyPI" + type: boolean + default: true + +jobs: + release: + runs-on: ubuntu-22.04 + permissions: + contents: write + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Ensure branch context + run: | + if [[ "${GITHUB_REF}" != refs/heads/* ]]; then + echo "::error::Release workflow must run against a branch ref." + exit 1 + fi + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.12" + + - name: Install build tooling + run: python -m pip install --upgrade pip setuptools wheel build twine + + - name: Update version file + id: bump_version + run: | + set -euo pipefail + VERSION=$(python scripts/update_version.py "${{ inputs.version }}") + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + - name: Build artifacts + run: | + rm -rf dist build + python -m build + twine check dist/* + + - name: Commit version bump + run: | + git config user.name "craftgate-bot" + git config user.email "actions@craftgate.io" + git add _version.py + if git diff --cached --quiet; then + echo "::error::No changes detected. Is the requested version already released?" + exit 1 + fi + git commit -m "Release v${{ steps.bump_version.outputs.version }}" + + - name: Create tag + run: git tag "v${{ steps.bump_version.outputs.version }}" + + - name: Push changes + env: + TARGET_BRANCH: ${{ github.ref }} + run: | + BRANCH_NAME="${TARGET_BRANCH#refs/heads/}" + git push origin "HEAD:${BRANCH_NAME}" + git push origin "v${{ steps.bump_version.outputs.version }}" + + - name: Publish to PyPI + if: inputs.publish_to_pypi == true + env: + TWINE_USERNAME: __token__ + TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} + run: twine upload dist/* + + - name: Create GitHub release + uses: softprops/action-gh-release@v1 + with: + tag_name: v${{ steps.bump_version.outputs.version }} + name: v${{ steps.bump_version.outputs.version }} + body: ${{ inputs.release_notes }} + generate_release_notes: true + files: | + dist/* diff --git a/scripts/check_init_files.py b/scripts/check_init_files.py new file mode 100644 index 0000000..c88339d --- /dev/null +++ b/scripts/check_init_files.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 +"""Fail if any python module lives outside a proper package. + +Every directory underneath craftgate/ that contains Python sources must own +an __init__.py file so the module becomes importable once packaged. This +script walks through the package tree and ensures all ancestors satisfy that +constraint. It prints the offending directories and exits with code 1 on +failure, otherwise exits cleanly. +""" + +from pathlib import Path +from typing import List, Set +import sys + + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +PACKAGE_ROOT = PROJECT_ROOT / "craftgate" +IGNORED_PARTS = {"__pycache__"} + + +def _relevant_parents(module_path: Path) -> List[Path]: + parents: List[Path] = [] + for parent in module_path.parents: + if parent == PACKAGE_ROOT.parent: + break + if parent.name in IGNORED_PARTS: + continue + parents.append(parent) + return parents + + +def main() -> int: + missing: Set[Path] = set() + + if not PACKAGE_ROOT.exists(): + print("craftgate package root not found", file=sys.stderr) + return 1 + + for py_file in PACKAGE_ROOT.rglob("*.py"): + if py_file.name == "__init__.py": + continue + if any(part in IGNORED_PARTS for part in py_file.parts): + continue + + for parent in _relevant_parents(py_file): + init_file = parent / "__init__.py" + if not init_file.exists(): + missing.add(parent.relative_to(PACKAGE_ROOT)) + + if missing: + print("Found python modules under directories missing __init__.py:", file=sys.stderr) + for relative in sorted(missing): + print(f"- craftgate/{relative.as_posix()}", file=sys.stderr) + return 1 + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/update_version.py b/scripts/update_version.py new file mode 100644 index 0000000..47073f5 --- /dev/null +++ b/scripts/update_version.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +"""Utility to bump the SDK version. + +Usage: + python scripts/update_version.py 1.2.3 + +The script normalizes the provided version (strips a leading "v" if present), +validates it loosely against SemVer rules and writes the value into the +_version.py file that is consumed by setuptools. It prints the normalized +version to stdout for downstream tooling. +""" + +from __future__ import annotations + +import argparse +import re +import sys +from pathlib import Path + +VERSION_PATTERN = re.compile(r"^v?(?P\d+\.\d+\.\d+(?:[0-9A-Za-z.\-+_]*)?)$") +PROJECT_ROOT = Path(__file__).resolve().parent.parent +VERSION_FILE = PROJECT_ROOT / "_version.py" + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Update _version.py") + parser.add_argument( + "version", + help="New version string (e.g. 1.2.3 or v1.2.3).", + ) + return parser.parse_args() + + +def normalize_version(raw: str) -> str: + match = VERSION_PATTERN.fullmatch(raw.strip()) + if not match: + raise ValueError( + "Version must look like 1.2.3 (optionally prefixed with v). " + f"Got: {raw!r}" + ) + return match.group("num") + + +def update_file(version: str) -> None: + VERSION_FILE.write_text(f'VERSION = "{version}"\n', encoding="utf-8") + + +def main() -> int: + args = parse_args() + try: + normalized = normalize_version(args.version) + except ValueError as exc: + print(f"Error: {exc}", file=sys.stderr) + return 1 + + update_file(normalized) + print(normalized) + return 0 + + +if __name__ == "__main__": + sys.exit(main())