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 concurrency: group: ci-python-app-${{ github.workflow }}-${{ github.ref }}-${{ inputs.source-dirs }}-${{ github.job }} cancel-in-progress: true steps: - uses: actions/checkout@v7 - 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 concurrency: group: ci-python-app-${{ github.workflow }}-${{ github.ref }}-${{ inputs.source-dirs }}-${{ github.job }} cancel-in-progress: true steps: - uses: actions/checkout@v7 - 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 concurrency: group: ci-python-app-${{ github.workflow }}-${{ github.ref }}-${{ inputs.source-dirs }}-${{ github.job }} cancel-in-progress: true steps: - uses: actions/checkout@v7 - 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 concurrency: group: ci-python-app-${{ github.workflow }}-${{ github.ref }}-${{ inputs.source-dirs }}-${{ github.job }} cancel-in-progress: true 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."