Skip to content
Closed
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
104 changes: 104 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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__)"
126 changes: 126 additions & 0 deletions .github/workflows/release-pypi.yml
Original file line number Diff line number Diff line change
@@ -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/
29 changes: 29 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading