Skip to content
Merged
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
50 changes: 50 additions & 0 deletions .github/workflows/craftgate-build.yml
Original file line number Diff line number Diff line change
@@ -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."
95 changes: 95 additions & 0 deletions .github/workflows/craftgate-release.yml
Original file line number Diff line number Diff line change
@@ -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/*
60 changes: 60 additions & 0 deletions scripts/check_init_files.py
Original file line number Diff line number Diff line change
@@ -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())
62 changes: 62 additions & 0 deletions scripts/update_version.py
Original file line number Diff line number Diff line change
@@ -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<num>\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())