diff --git a/.github/workflows/ci-python-app.yaml b/.github/workflows/ci-python-app.yaml new file mode 100644 index 0000000..42c42e2 --- /dev/null +++ b/.github/workflows/ci-python-app.yaml @@ -0,0 +1,150 @@ +name: CI — Python (app) + +# Reusable CI for plain Python apps / locally-run tooling that do NOT deploy via +# SAM or CDK (use ci-python-sam.yaml / ci-typescript-cdk.yaml for those). Beyond +# lint + format it adds two things such repos commonly need: +# * a collect-only import check for a root suite whose live run needs secrets +# (verifies every test module imports cleanly without running them), and +# * an isolated full pytest run for a self-contained subproject dir whose tests +# package collides with the root tests/ package (e.g. a `tests/` under a +# subdir) and so must run in its own working directory. +# +# Naming is load-bearing (see this repo's ci.yaml): the org ruleset matches the +# required `ci / ci` check against the JOB check-run name. A caller job keyed `ci` +# invoking this workflow reports each job here as `ci / `, so the aggregator +# job below is keyed `ci` to emit exactly `ci / ci`. The aggregator gates on every +# other job, so the single required check fails if any sub-job fails. + +on: + workflow_call: + inputs: + python-version: + description: "Python version to use" + type: string + default: "3.12" + source-dirs: + description: "Space-separated directories for ruff (default: repo root)" + type: string + default: "." + requirements: + description: "Requirements file used for the pip cache key + install" + type: string + default: "requirements.txt" + collect-only: + description: "Run 'pytest --collect-only' at the repo root (imports resolve without secrets)" + type: boolean + default: true + subproject-dir: + description: "Optional self-contained subproject dir whose pytest suite runs in full" + type: string + default: "" + run-conventions-check: + description: "Run the lightweight conventions audit (README + .gitignore covers .env)" + type: boolean + default: true + +permissions: + contents: read + +jobs: + lint: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-python@v6 + with: + python-version: ${{ inputs.python-version }} + + - name: Install ruff + run: pip install ruff + + - name: Ruff check + run: ruff check ${{ inputs.source-dirs }} + + - name: Ruff format check + run: ruff format --check ${{ inputs.source-dirs }} + + - name: Conventions check + if: ${{ inputs.run-conventions-check }} + run: | + errors=0 + fail() { echo "::error::$1"; errors=$((errors + 1)); } + + # README must exist + if [[ ! -f README.md ]]; then + fail "Missing README.md" + fi + + # .gitignore must cover .env + if [[ -f .gitignore ]]; then + if ! grep -qE '^\.env$|^\.env\b' .gitignore; then + fail ".gitignore does not include .env" + fi + else + fail "Missing .gitignore" + fi + + if [[ $errors -gt 0 ]]; then + echo "Conventions check failed with $errors error(s)." + exit 1 + fi + echo "Conventions check passed." + + test-collect: + if: ${{ inputs.collect-only }} + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-python@v6 + with: + python-version: ${{ inputs.python-version }} + cache: pip + cache-dependency-path: ${{ inputs.requirements }} + + - name: Install dependencies + run: | + pip install -r "${{ inputs.requirements }}" + pip install pytest python-dotenv + + - name: Pytest collect-only + run: pytest --collect-only -q + + subproject-tests: + if: ${{ inputs.subproject-dir != '' }} + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-python@v6 + with: + python-version: ${{ inputs.python-version }} + cache: pip + cache-dependency-path: ${{ inputs.requirements }} + + - name: Install dependencies + run: | + pip install -r "${{ inputs.requirements }}" + pip install pytest + + - name: Run subproject suite + working-directory: ${{ inputs.subproject-dir }} + run: python -m pytest -q + + ci: + # Aggregator — keyed `ci` so a caller job keyed `ci` reports `ci / ci`. + needs: [lint, test-collect, subproject-tests] + if: always() + runs-on: ubuntu-latest + steps: + - name: Require all jobs to have succeeded + run: | + if [ "${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') }}" = "true" ]; then + echo "A required CI job failed or was cancelled." + exit 1 + fi + echo "All CI jobs passed."