feat(automation): surface real GitHub dispatch error + add bulk workflow dispatch #1341
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: CI | |
| on: | |
| pull_request: | |
| push: | |
| branches: | |
| - "**" | |
| permissions: | |
| contents: read | |
| jobs: | |
| commits: | |
| name: Commit Messages (Conventional Commits) | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Checkout (full history) | |
| uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 | |
| - name: Setup Node | |
| uses: actions/setup-node@v4 | |
| with: | |
| node-version: 22 | |
| - name: Install commitlint | |
| run: npm ci --ignore-scripts | |
| - name: Lint commits (PR) | |
| if: ${{ github.event_name == 'pull_request' }} | |
| run: npx commitlint --from ${{ github.event.pull_request.base.sha }} --to ${{ github.sha }} --verbose | |
| - name: Lint commits (push) | |
| if: ${{ github.event_name == 'push' && github.ref != 'refs/heads/main' && github.event.before != '0000000000000000000000000000000000000000' }} | |
| # github.event.before can be a commit no longer reachable from github.sha (e.g. after a | |
| # rebase or force-push rewrites history), which makes the range invalid and this step | |
| # fail even though nothing is actually wrong with the commit messages. When that happens, | |
| # skip rather than fail: if this branch has an open PR, the pull_request-triggered lint | |
| # job above already covers the same commits against a stable base.sha. | |
| run: | | |
| if git merge-base --is-ancestor ${{ github.event.before }} ${{ github.sha }} 2>/dev/null; then | |
| npx commitlint --from ${{ github.event.before }} --to ${{ github.sha }} --verbose | |
| else | |
| echo "::notice::${{ github.event.before }} is not an ancestor of ${{ github.sha }} (likely a rebase/force-push) — skipping push-triggered lint for this range." | |
| fi | |
| ui: | |
| name: UI Typecheck + Test + Build | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Checkout (full history for diff coverage) | |
| uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 | |
| - name: Setup Bun | |
| uses: oven-sh/setup-bun@v2 | |
| with: | |
| bun-version: latest | |
| - name: Install UI deps | |
| working-directory: apps/ui | |
| run: bun install --frozen-lockfile | |
| - name: Typecheck | |
| working-directory: apps/ui | |
| run: bun run typecheck | |
| - name: Lint UI | |
| working-directory: apps/ui | |
| run: bun run lint | |
| # Runs all tests and enforces the global coverage floor (apps/ui/vitest.config.ts | |
| # test.coverage.thresholds) — a regression guard, not an aspirational target; most | |
| # app/** page components aren't unit-tested yet. New/changed lines are held to a much | |
| # higher bar by the diff-coverage step below. | |
| - name: Test UI (with coverage) | |
| working-directory: apps/ui | |
| run: bun run test:coverage | |
| - name: Build UI | |
| working-directory: apps/ui | |
| run: bun run build | |
| - name: Setup Python (for diff-cover) | |
| uses: actions/setup-python@v5 | |
| with: | |
| python-version: "3.12" | |
| - name: Install diff-cover | |
| run: python -m pip install diff-cover==9.4.1 | |
| # vitest's lcov report has SF: paths relative to apps/ui (its configured `root`), but | |
| # diff-cover matches against `git diff` paths, which are repo-root-relative. Rewrite the | |
| # paths to be repo-root-relative before handing off, then run diff-cover from the repo | |
| # root so its own path resolution lines up. (Verified locally: without this rewrite, | |
| # diff-cover silently reports "No lines with coverage information" instead of failing.) | |
| - name: Diff coverage (new/changed UI lines must be tested) | |
| run: | | |
| sed -E 's#^SF:(.*)$#SF:apps/ui/\1#' apps/ui/coverage/lcov.info | tr '\134' '/' > apps/ui/coverage/lcov-root-relative.info | |
| diff-cover apps/ui/coverage/lcov-root-relative.info --compare-branch=origin/main --fail-under=90 | |
| python: | |
| name: Python Tests + Compile | |
| runs-on: ubuntu-latest | |
| services: | |
| postgres: | |
| image: postgres:17-alpine | |
| env: | |
| POSTGRES_DB: clevis | |
| POSTGRES_USER: clevis | |
| POSTGRES_PASSWORD: clevis | |
| ports: | |
| - 5432:5432 | |
| options: >- | |
| --health-cmd "pg_isready -U clevis" | |
| --health-interval 10s | |
| --health-timeout 5s | |
| --health-retries 5 | |
| # Issue #191/S3: webhook ingestion queue. Not used by any test yet in this PR | |
| # (infra scaffolding only), but required for Settings() to construct (redis_url | |
| # has no default) and wired up now so PR 3's Redis-backed tests don't need a | |
| # separate CI change. | |
| redis: | |
| image: redis:7-alpine | |
| ports: | |
| - 6379:6379 | |
| options: >- | |
| --health-cmd "redis-cli ping" | |
| --health-interval 10s | |
| --health-timeout 5s | |
| --health-retries 5 | |
| env: | |
| # Superuser URL: used for migrations and every step except "Run Python tests" below, | |
| # which overrides DATABASE_URL to the clevis_api role instead (issue #330/#190 -- | |
| # proves RLS actually enforces tenant isolation under the real non-superuser runtime | |
| # role, not just a hand-built throwaway test role). | |
| DATABASE_URL: postgresql+psycopg://clevis:clevis@localhost:5432/clevis | |
| API_DB_PASSWORD: ci-dummy-api-db-password-not-for-production-use-0 | |
| JOB_SECRET_KEY: ${{ secrets.JOB_SECRET_KEY || 'ci-dummy-secret-key-not-for-production-use-00' }} | |
| AUTH_SECRET: ${{ secrets.AUTH_SECRET || 'ci-dummy-auth-secret-not-for-production-use-000' }} | |
| REDIS_URL: redis://localhost:6379/0 | |
| steps: | |
| - name: Checkout (full history for diff coverage) | |
| uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 | |
| - name: Setup Python | |
| uses: actions/setup-python@v5 | |
| with: | |
| python-version: "3.12" | |
| - name: Install API/Worker + test requirements | |
| run: | | |
| python -m pip install --upgrade pip | |
| python -m pip install -r apps/api/requirements.txt | |
| python -m pip install -r apps/worker/requirements.txt | |
| python -m pip install -e packages/checks | |
| python -m pip install -r requirements-test.txt | |
| - name: Run migrations | |
| working-directory: apps/api | |
| run: python -m alembic upgrade head | |
| - name: Check for model / migration drift | |
| working-directory: apps/api | |
| run: python -m alembic check | |
| # Reuses the exact same script an operator runs by hand for an existing deployment | |
| # (docs/self-hosting.md) instead of duplicating its grant SQL here -- keeps CI's | |
| # provisioning and the documented manual path from drifting apart. Connects over TCP | |
| # to the service container (not a docker exec local-socket connection like the | |
| # script's usual home), so auth needs PGPASSWORD/PGHOST/PGPORT explicitly. | |
| - name: Provision clevis_api role (issue #330 -- non-superuser runtime role) | |
| env: | |
| PGHOST: localhost | |
| PGPORT: 5432 | |
| PGPASSWORD: clevis | |
| POSTGRES_USER: clevis | |
| POSTGRES_DB: clevis | |
| run: sh docker/provision-api-role-existing-deployment.sh | |
| # Global floor (.coveragerc / --cov-fail-under) is a regression guard measured against | |
| # the current baseline (~87%), not an aspirational target — it stops overall coverage | |
| # from silently eroding. New/changed lines are held to a much higher bar by the diff | |
| # coverage step below. | |
| # | |
| # DATABASE_URL is overridden here (not at job level) to the clevis_api role | |
| # provisioned above -- issue #330/#190: this is what actually exercises the RLS | |
| # policies (migrations 0030/0031) against a real non-superuser, non-owner role, the | |
| # same posture as production when API_DB_PASSWORD is configured. Every other step | |
| # in this job (migrations, drift check, coverage upload) keeps using the job-level | |
| # superuser DATABASE_URL, since Alembic needs DDL/GRANT privilege this role | |
| # intentionally lacks. | |
| - name: Run Python tests (with coverage) | |
| env: | |
| DATABASE_URL: postgresql+psycopg://clevis_api:${{ env.API_DB_PASSWORD }}@localhost:5432/clevis | |
| run: > | |
| python -m pytest -q | |
| --cov=apps/api/src --cov=apps/worker/src --cov=packages/checks/src | |
| --cov-report=xml --cov-report=term --cov-fail-under=85 | |
| - name: Diff coverage (new/changed Python lines must be tested) | |
| run: diff-cover coverage.xml --compare-branch=origin/main --fail-under=90 | |
| - name: Compile Python sources | |
| run: python -m compileall apps/api/src apps/worker/src | |
| e2e: | |
| name: E2E Tests | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v4 | |
| # Dummy secrets, same pattern as the "python" job — this stack is thrown away at the | |
| # end of the job, nothing here is a real credential. | |
| - name: Write CI env file | |
| run: | | |
| cat > .env <<'EOF' | |
| DB_USER=clevis | |
| DB_PASSWORD=clevis | |
| DB_NAME=clevis | |
| JOB_SECRET_KEY=ci-e2e-secret-key-not-for-production-use-0000 | |
| AUTH_SECRET=ci-e2e-auth-secret-not-for-production-use-00000 | |
| NEXT_PUBLIC_API_BASE=http://localhost:8080 | |
| NEXT_PUBLIC_GITHUB_APP_SLUG= | |
| REDIS_PASSWORD=ci-e2e-redis-password-not-for-production-use-000 | |
| EOF | |
| # Full docker-compose stack (not direct processes) so this exercises the real images — | |
| # the same ones verified in Docker Build Verification — not just app code in isolation. | |
| - name: Start stack | |
| run: docker compose -f docker-compose.yml -f docker-compose.ci.yml --profile backend --profile frontend up --build -d | |
| - name: Wait for API and UI to be reachable | |
| run: | | |
| for i in $(seq 1 60); do | |
| api_ok=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:8080/healthz || echo "000") | |
| ui_ok=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:3000/login || echo "000") | |
| if [ "$api_ok" = "200" ] && [ "$ui_ok" = "200" ]; then | |
| echo "API and UI are up" | |
| exit 0 | |
| fi | |
| sleep 2 | |
| done | |
| echo "Timed out waiting for API/UI" | |
| docker compose -f docker-compose.yml -f docker-compose.ci.yml logs | |
| exit 1 | |
| - name: Setup Bun | |
| uses: oven-sh/setup-bun@v2 | |
| with: | |
| bun-version: latest | |
| - name: Install UI deps | |
| working-directory: apps/ui | |
| run: bun install --frozen-lockfile | |
| - name: Install Playwright browser (Chromium only) | |
| working-directory: apps/ui | |
| run: bunx playwright install --with-deps chromium | |
| - name: Run E2E tests | |
| working-directory: apps/ui | |
| env: | |
| E2E_BASE_URL: http://localhost:3000 | |
| E2E_API_BASE: http://localhost:8080 | |
| CI: "true" | |
| run: bun run test:e2e | |
| - name: Upload Playwright report | |
| if: failure() | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: playwright-report | |
| path: apps/ui/playwright-report/ | |
| retention-days: 7 | |
| - name: Stack logs (on failure) | |
| if: failure() | |
| run: docker compose -f docker-compose.yml -f docker-compose.ci.yml logs | |
| - name: Tear down stack | |
| if: always() | |
| run: docker compose -f docker-compose.yml -f docker-compose.ci.yml down -v | |
| docker: | |
| name: Docker Build Verification | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v4 | |
| - name: Build API image | |
| run: docker build -t clevis-api -f apps/api/Dockerfile . | |
| - name: Build Worker image | |
| run: docker build -t clevis-worker -f apps/worker/Dockerfile . | |
| - name: Build UI image | |
| run: docker build -t clevis-ui -f apps/ui/Dockerfile apps/ui | |
| # Building an image only proves the Dockerfile's instructions succeed — it does not | |
| # prove the app can actually import/start (e.g. a missing runtime dependency like | |
| # packages/checks not being installed in an image). Smoke-test by importing the | |
| # entrypoint module directly, bypassing entrypoint.sh so no live DB is needed. | |
| - name: Smoke-test API image | |
| run: | | |
| docker run --rm --entrypoint python \ | |
| -e DATABASE_URL=postgresql+psycopg://smoke:smoke@localhost:5432/smoke \ | |
| -e JOB_SECRET_KEY=ci-smoke-test-key-not-for-production-use-000 \ | |
| -e AUTH_SECRET=ci-smoke-test-secret-not-for-production-use-00 \ | |
| -e REDIS_URL=redis://smoke:6379/0 \ | |
| clevis-api -c "import src.main" | |
| - name: Smoke-test Worker image | |
| run: | | |
| docker run --rm --entrypoint python \ | |
| -e DATABASE_URL=postgresql://smoke:smoke@localhost:5432/smoke \ | |
| -e JOB_SECRET_KEY=ci-smoke-test-key-not-for-production-use-000 \ | |
| -e REDIS_URL=redis://smoke:6379/0 \ | |
| clevis-worker -c "import worker" |