From be4a2e82da81955efbe98e93ccf0c503dbd14d2f Mon Sep 17 00:00:00 2001 From: Ghazanfar Taqi Date: Wed, 1 Apr 2026 07:13:32 +0500 Subject: [PATCH] Automate Build, Review, and Publish to PyPI --- .github/workflows/ci.yml | 104 ++++++++++++++++++++++++ .github/workflows/release-pypi.yml | 126 +++++++++++++++++++++++++++++ CONTRIBUTING.md | 29 +++++++ README.md | 24 ++++++ 4 files changed, 283 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release-pypi.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..ff4a93c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,104 @@ +name: CI + +on: + push: + branches: + - main + pull_request: + +permissions: + contents: read + +jobs: + tests: + name: Tests (Python ${{ matrix.python-version }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install test dependencies + run: | + python -m pip install --upgrade pip + pip install -e .[test] + + - name: Run test suite + run: pytest + + package-validation: + name: Build and Validate Package + runs-on: ubuntu-latest + needs: tests + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Build package artifacts + run: | + python -m pip install --upgrade pip + pip install build twine + python -m build + + - name: Validate package metadata + run: twine check dist/* + + - name: Validate package contents + run: | + python - <<'PY' + import pathlib + import tarfile + import zipfile + + dist = pathlib.Path("dist") + sdist = next(dist.glob("*.tar.gz"), None) + wheel = next(dist.glob("*.whl"), None) + + assert sdist is not None, "sdist artifact missing" + assert wheel is not None, "wheel artifact missing" + + with tarfile.open(sdist, "r:gz") as tf: + sdist_names = tf.getnames() + with zipfile.ZipFile(wheel) as zf: + wheel_names = zf.namelist() + + required_sdist = [ + "pyproject.toml", + "README.md", + "src/cloud_insight_ai/__init__.py", + ] + required_wheel = [ + "cloud_insight_ai/__init__.py", + "cloud_insight_ai/py.typed", + ] + + for filename in required_sdist: + if not any(name.endswith(filename) for name in sdist_names): + raise AssertionError(f"Missing {filename} in sdist") + + for filename in required_wheel: + if not any(name.endswith(filename) for name in wheel_names): + raise AssertionError(f"Missing {filename} in wheel") + + print("Package content validation passed") + PY + + - name: Test install from built wheel + run: | + pip install --force-reinstall dist/*.whl + python -c "import cloud_insight_ai; print(cloud_insight_ai.__version__)" diff --git a/.github/workflows/release-pypi.yml b/.github/workflows/release-pypi.yml new file mode 100644 index 0000000..5860570 --- /dev/null +++ b/.github/workflows/release-pypi.yml @@ -0,0 +1,126 @@ +name: Release to PyPI + +on: + release: + types: [published] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +jobs: + validate-release: + name: Validate Release Build + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Verify release tag matches package version + if: github.event_name == 'release' + run: | + python - <<'PY' + import tomllib + from pathlib import Path + + data = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8")) + package_version = data["project"]["version"] + release_tag = "${{ github.event.release.tag_name }}" + normalized_tag = release_tag[1:] if release_tag.startswith("v") else release_tag + + if normalized_tag != package_version: + raise SystemExit( + f"Release tag '{release_tag}' does not match project version '{package_version}'" + ) + + print(f"Version check passed: {release_tag} -> {package_version}") + PY + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Run tests + run: | + python -m pip install --upgrade pip + pip install -e .[test] + pytest + + - name: Build distributions + run: | + pip install build twine + python -m build + twine check dist/* + + - name: Validate package contents + run: | + python - <<'PY' + import pathlib + import tarfile + import zipfile + + dist = pathlib.Path("dist") + sdist = next(dist.glob("*.tar.gz"), None) + wheel = next(dist.glob("*.whl"), None) + + assert sdist is not None, "sdist artifact missing" + assert wheel is not None, "wheel artifact missing" + + with tarfile.open(sdist, "r:gz") as tf: + sdist_names = tf.getnames() + with zipfile.ZipFile(wheel) as zf: + wheel_names = zf.namelist() + + required_sdist = [ + "pyproject.toml", + "README.md", + "src/cloud_insight_ai/__init__.py", + ] + required_wheel = [ + "cloud_insight_ai/__init__.py", + "cloud_insight_ai/py.typed", + ] + + for filename in required_sdist: + if not any(name.endswith(filename) for name in sdist_names): + raise AssertionError(f"Missing {filename} in sdist") + + for filename in required_wheel: + if not any(name.endswith(filename) for name in wheel_names): + raise AssertionError(f"Missing {filename} in wheel") + + print("Release artifact content validation passed") + PY + + - name: Upload dist artifacts + uses: actions/upload-artifact@v4 + with: + name: python-package-distributions + path: dist/ + + publish: + name: Publish to PyPI + needs: validate-release + runs-on: ubuntu-latest + environment: + name: pypi + permissions: + id-token: write + + steps: + - name: Download dist artifacts + uses: actions/download-artifact@v4 + with: + name: python-package-distributions + path: dist/ + + - name: Publish package to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: dist/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index edf97dd..6ebdfd8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -261,6 +261,35 @@ pytest tests/test_analyzer.py pytest tests/test_analyzer.py::test_cost_analysis ``` +### CI Package Validation + +All pull requests and changes to `main` are validated by GitHub Actions: + +- Test matrix on Python 3.8 to 3.12 +- Distribution build (`sdist` + `wheel`) +- Metadata validation using `twine check` +- Distribution content verification for required package files + +If CI fails, please fix the reported issue before requesting review. + +--- + +## 📦 Release Process + +PyPI publishing is automated and gated: + +1. Create or update release-ready changes. +2. Publish a GitHub Release. +3. The `Release to PyPI` workflow runs tests and package validations. +4. A maintainer approves the protected `pypi` environment. +5. The package is published to PyPI using Trusted Publishing (OIDC). + +### Security Model + +- No PyPI API token is stored in repository secrets. +- Publishing uses short-lived OIDC credentials via `pypa/gh-action-pypi-publish`. +- Only approved runs in the `pypi` environment can publish. + ### Writing Tests Place tests in the `tests/` directory: diff --git a/README.md b/README.md index 5383764..4302def 100644 --- a/README.md +++ b/README.md @@ -161,6 +161,30 @@ pytest --cov=cloud_insight_ai --- +## 🚀 Release Automation + +This project uses GitHub Actions to automate quality checks and publishing. + +- `CI` workflow (`.github/workflows/ci.yml`) + - Runs tests on Python 3.8 to 3.12 + - Builds both `sdist` and `wheel` + - Runs `twine check` to validate package metadata + - Verifies key files are present in the built distributions +- `Release to PyPI` workflow (`.github/workflows/release-pypi.yml`) + - Triggers on published GitHub Releases (or manual dispatch) + - Runs tests and build validation again for release safety + - Publishes only after the `pypi` environment is approved + - Uses PyPI Trusted Publishing (OIDC), so no PyPI API token is stored in repository secrets + +### One-time setup for maintainers + +1. In PyPI, configure this repository as a Trusted Publisher. +2. In GitHub, create an environment named `pypi`. +3. Add required reviewers to the `pypi` environment for release approval. +4. Publish a GitHub Release to trigger deployment to PyPI. + +--- + ## 🤝 Contributing Contributions are welcome! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.