name: CI Pipeline on: push: branches: [main, develop] pull_request: branches: [main] concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: # ────────────────────────────────────────────────── # Stage 1: Lint + Type Check (fast fail) # ────────────────────────────────────────────────── lint: name: Lint & Type Check runs-on: ubuntu-latest timeout-minutes: 10 steps: - uses: actions/checkout@v4 - uses: oven-sh/setup-bun@v2 with: bun-version: latest - name: Install dependencies run: | for i in 1 2 3; do bun install --frozen-lockfile && exit 0 echo "bun install attempt $i failed, retrying in 10s..." sleep 10 done echo "::error::bun install failed after 3 attempts" exit 1 - name: Run ESLint run: bun run lint - name: Run TypeScript check run: bun run typecheck docs-parity: name: Docs Parity runs-on: ubuntu-latest timeout-minutes: 10 steps: - uses: actions/checkout@v4 - uses: oven-sh/setup-bun@v2 with: bun-version: latest - name: Install dependencies run: | for i in 1 2 3; do bun install --frozen-lockfile && exit 0 echo "bun install attempt $i failed, retrying in 10s..." sleep 10 done echo "::error::bun install failed after 3 attempts" exit 1 - name: Check docs parity run: bun run docs:parity wiring-audit: name: Wiring Audit runs-on: ubuntu-latest timeout-minutes: 10 steps: - uses: actions/checkout@v4 - uses: oven-sh/setup-bun@v2 with: bun-version: latest - name: Install dependencies run: | for i in 1 2 3; do bun install --frozen-lockfile && exit 0 echo "bun install attempt $i failed, retrying in 10s..." sleep 10 done echo "::error::bun install failed after 3 attempts" exit 1 - name: Run wiring audit run: bun run wiring:audit openapi-coverage: name: OpenAPI Coverage runs-on: ubuntu-latest timeout-minutes: 10 steps: - uses: actions/checkout@v4 - uses: oven-sh/setup-bun@v2 with: bun-version: latest - name: Install dependencies run: | for i in 1 2 3; do bun install --frozen-lockfile && exit 0 echo "bun install attempt $i failed, retrying in 10s..." sleep 10 done echo "::error::bun install failed after 3 attempts" exit 1 - name: Check OpenAPI coverage run: bun run openapi:coverage env: OPENAPI_COVERAGE_THRESHOLD: "60" # ────────────────────────────────────────────────── # Stage 2: Security Audit # ────────────────────────────────────────────────── security: name: Security Gates runs-on: ubuntu-latest timeout-minutes: 10 steps: - uses: actions/checkout@v4 - uses: oven-sh/setup-bun@v2 with: bun-version: latest - name: Install dependencies run: | for i in 1 2 3; do bun install --frozen-lockfile && exit 0 echo "bun install attempt $i failed, retrying in 10s..." sleep 10 done echo "::error::bun install failed after 3 attempts" exit 1 - name: Dependency audit run: bun run security:audit # ────────────────────────────────────────────────── # Stage 3: Unit & Integration Tests + Coverage # ────────────────────────────────────────────────── test: name: Unit & Integration Tests runs-on: ubuntu-latest needs: lint timeout-minutes: 15 services: postgres: image: postgres:16-alpine env: POSTGRES_USER: test POSTGRES_PASSWORD: test POSTGRES_DB: test ports: - 5432:5432 options: >- --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 steps: - uses: actions/checkout@v4 - uses: oven-sh/setup-bun@v2 with: bun-version: latest - name: Install dependencies run: | for i in 1 2 3; do bun install --frozen-lockfile && exit 0 echo "bun install attempt $i failed, retrying in 10s..." sleep 10 done echo "::error::bun install failed after 3 attempts" exit 1 - name: Run tests with coverage run: bun run test:coverage env: DATABASE_URL: postgresql://test:test@localhost:5432/test - name: Coverage threshold check (>50%) run: | if [ -f coverage/coverage-summary.json ]; then LINE_PCT=$(bun -e " const data = JSON.parse(require('fs').readFileSync('coverage/coverage-summary.json','utf8')); console.log(data.total?.lines?.pct ?? 0); ") echo "Line coverage: ${LINE_PCT}%" if (( $(echo "$LINE_PCT < 50" | bc -l 2>/dev/null || echo 0) )); then echo "::error::Coverage ${LINE_PCT}% is below 50% threshold" exit 1 fi else echo "::warning::Coverage report not found, skipping threshold check" fi - name: Upload coverage if: always() uses: codecov/codecov-action@v4 with: file: ./coverage/lcov.info fail_ci_if_error: false - name: Upload coverage artifact if: always() uses: actions/upload-artifact@v4 with: name: coverage-report path: coverage/ retention-days: 14 # ────────────────────────────────────────────────── # Stage 3b: Contract Tests (Lane: contract) # ────────────────────────────────────────────────── contract: name: Contract Tests (Lane: contract) runs-on: ubuntu-latest needs: lint timeout-minutes: 10 steps: - uses: actions/checkout@v4 - uses: oven-sh/setup-bun@v2 with: bun-version: latest - name: Install dependencies run: | for i in 1 2 3; do bun install --frozen-lockfile && exit 0 echo "bun install attempt $i failed, retrying in 10s..." sleep 10 done echo "::error::bun install failed after 3 attempts" exit 1 - name: Run contract tests run: bun run test:contract # ────────────────────────────────────────────────── # Stage 3c: Smoke Tests (Lane: smoke) # ────────────────────────────────────────────────── smoke: name: Smoke Tests (Lane: smoke) runs-on: ubuntu-latest needs: lint timeout-minutes: 10 steps: - uses: actions/checkout@v4 - uses: oven-sh/setup-bun@v2 with: bun-version: latest - name: Install dependencies run: | for i in 1 2 3; do bun install --frozen-lockfile && exit 0 echo "bun install attempt $i failed, retrying in 10s..." sleep 10 done echo "::error::bun install failed after 3 attempts" exit 1 - name: Run smoke tests run: bun run test:smoke # ────────────────────────────────────────────────── # Stage 4: E2E Tests (Playwright) # ────────────────────────────────────────────────── e2e: name: E2E Tests (Playwright) runs-on: ubuntu-latest needs: test timeout-minutes: 20 services: postgres: image: postgres:16-alpine env: POSTGRES_USER: test POSTGRES_PASSWORD: test POSTGRES_DB: test ports: - 5432:5432 options: >- --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 steps: - uses: actions/checkout@v4 - uses: oven-sh/setup-bun@v2 with: bun-version: latest - name: Install dependencies run: | for i in 1 2 3; do bun install --frozen-lockfile && exit 0 echo "bun install attempt $i failed, retrying in 10s..." sleep 10 done echo "::error::bun install failed after 3 attempts" exit 1 - name: Install Playwright browsers run: bunx playwright install --with-deps chromium - name: Push Drizzle schema to test database env: DATABASE_URL: postgresql://test:test@localhost:5432/test DATABASE_DRIVER: postgres run: bunx drizzle-kit push --force - name: Run E2E tests run: bunx playwright test env: DATABASE_URL: postgresql://test:test@localhost:5432/test DATABASE_DRIVER: postgres JWT_SECRET: "ci-test-secret-key-for-e2e-tests" SITE_URL: "http://localhost:4321" INTERNAL_HOOK_SECRET: "ci-test-internal-hook-secret" CSRF_SKIP_DEV: "true" RATE_LIMIT_SKIP_DEV: "true" - name: Upload Playwright report if: always() uses: actions/upload-artifact@v4 with: name: playwright-report path: playwright-report/ retention-days: 14 - name: Upload test results if: always() uses: actions/upload-artifact@v4 with: name: test-results path: test-results/ retention-days: 7 # ────────────────────────────────────────────────── # Stage 5: Container Security Scan (Trivy) # ────────────────────────────────────────────────── container-scan: name: Container Security (Trivy) runs-on: ubuntu-latest needs: lint timeout-minutes: 15 steps: - uses: actions/checkout@v4 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Build Docker image uses: docker/build-push-action@v5 with: context: . load: true tags: opencodehub:ci file: Dockerfile cache-from: type=gha cache-to: type=gha,mode=max - name: Run Trivy vulnerability scanner uses: aquasecurity/trivy-action@master with: image-ref: "opencodehub:ci" format: "sarif" output: "trivy-results.sarif" severity: "CRITICAL,HIGH" exit-code: "0" - name: Upload Trivy scan results if: always() uses: github/codeql-action/upload-sarif@v3 with: sarif_file: "trivy-results.sarif" continue-on-error: true # ────────────────────────────────────────────────── # Stage 6: SAST (Semgrep) # ────────────────────────────────────────────────── sast: name: SAST (Semgrep) runs-on: ubuntu-latest needs: lint timeout-minutes: 10 container: image: semgrep/semgrep steps: - uses: actions/checkout@v4 - name: Run Semgrep run: semgrep scan --config auto --error --json --output semgrep-results.json src/ || true env: SEMGREP_RULES: >- p/typescript p/javascript p/security-audit - name: Upload Semgrep results if: always() uses: actions/upload-artifact@v4 with: name: semgrep-results path: semgrep-results.json retention-days: 14 # ────────────────────────────────────────────────── # Stage 7: Performance Gate # ────────────────────────────────────────────────── performance: name: Performance Gate runs-on: ubuntu-latest needs: [build] timeout-minutes: 10 services: postgres: image: postgres:16-alpine env: POSTGRES_USER: test POSTGRES_PASSWORD: test POSTGRES_DB: test ports: - 5432:5432 options: >- --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 steps: - uses: actions/checkout@v4 - uses: oven-sh/setup-bun@v2 with: bun-version: latest - name: Install dependencies run: | for i in 1 2 3; do bun install --frozen-lockfile && exit 0 echo "bun install attempt $i failed, retrying in 10s..." sleep 10 done echo "::error::bun install failed after 3 attempts" exit 1 - name: Build application run: bun run build env: DATABASE_URL: postgresql://test:test@localhost:5432/test DATABASE_DRIVER: postgres - name: Start server and run perf check run: | HOST=127.0.0.1 PORT=4321 node ./dist/server/entry.mjs & SERVER_PID=$! sleep 5 curl -s http://127.0.0.1:4321/api/health > /dev/null || true curl -s http://127.0.0.1:4321/api/metrics > /dev/null || true bun run perf:baseline kill $SERVER_PID 2>/dev/null || true env: DATABASE_URL: postgresql://test:test@localhost:5432/test DATABASE_DRIVER: postgres JWT_SECRET: "ci-perf-secret" SITE_URL: http://127.0.0.1:4321 INTERNAL_HOOK_SECRET: "ci-perf-hook" CSRF_SKIP_DEV: "true" RATE_LIMIT_SKIP_DEV: "true" RATE_LIMIT_ENABLED: "false" SKIP_REDIS_CHECK: "1" PERF_BASE_URL: http://127.0.0.1:4321 PERF_PATHS: /api/health,/api/metrics PERF_CONCURRENCY: 4 PERF_REQUESTS: 50 PERF_MAX_P95_MS: 500 PERF_OUTPUT: test-results/perf-report.json - name: Upload perf report if: always() uses: actions/upload-artifact@v4 with: name: perf-report path: test-results/perf-report.json retention-days: 14 # ────────────────────────────────────────────────── # Stage 8: Build Verification # ────────────────────────────────────────────────── build: name: Build runs-on: ubuntu-latest needs: [lint, docs-parity, security, test] timeout-minutes: 10 steps: - uses: actions/checkout@v4 - uses: oven-sh/setup-bun@v2 with: bun-version: latest - name: Install dependencies run: | for i in 1 2 3; do bun install --frozen-lockfile && exit 0 echo "bun install attempt $i failed, retrying in 10s..." sleep 10 done echo "::error::bun install failed after 3 attempts" exit 1 - name: Build application run: bun run build env: SKIP_REDIS_CHECK: "1" - name: Upload build artifacts uses: actions/upload-artifact@v4 with: name: dist path: dist/ retention-days: 7 # ────────────────────────────────────────────────── # Stage 8: Docker Build & Push # ────────────────────────────────────────────────── docker: name: Docker Build runs-on: ubuntu-latest needs: [build, e2e, container-scan, performance] if: github.event_name == 'push' && github.ref == 'refs/heads/main' steps: - uses: actions/checkout@v4 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Login to Container Registry (Docker Hub) uses: docker/login-action@v3 with: registry: docker.io username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_TOKEN }} - name: Extract metadata id: meta uses: docker/metadata-action@v5 with: images: ${{ secrets.DOCKER_USERNAME }}/opencodehub tags: | type=ref,event=branch type=sha - name: Build and push uses: docker/build-push-action@v5 with: context: . push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha cache-to: type=gha,mode=max # ────────────────────────────────────────────────── # Stage 9: Publish CLI Package # ────────────────────────────────────────────────── cli-publish: name: Publish CLI runs-on: ubuntu-latest needs: [build, e2e] if: github.event_name == 'push' && github.ref == 'refs/heads/main' permissions: contents: read id-token: write steps: - uses: actions/checkout@v4 - uses: oven-sh/setup-bun@v2 with: bun-version: latest - name: Set up Node.js uses: actions/setup-node@v4 with: node-version: "20" registry-url: "https://registry.npmjs.org" - name: Install dependencies run: | for i in 1 2 3; do bun install --frozen-lockfile && exit 0 echo "bun install attempt $i failed, retrying in 10s..." sleep 10 done echo "::error::bun install failed after 3 attempts" exit 1 - name: Build CLI run: bun run build working-directory: cli - name: Publish to npm working-directory: cli env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} run: | # Check if package version already exists on registry PKG_NAME=$(node -p "require('./package.json').name") PKG_VERSION=$(node -p "require('./package.json').version") HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" "https://registry.npmjs.org/${PKG_NAME}/${PKG_VERSION}") if [ "$HTTP_CODE" = "200" ]; then echo "::warning::Version ${PKG_VERSION} already exists on npm, skipping publish" exit 0 fi # First publish of a new package: --provenance requires the package to exist # so we attempt with provenance first, fall back without it on 404 if npm publish --access public --provenance 2>&1 | tee /tmp/npm-publish.log; then echo "Published with provenance" elif grep -q "404" /tmp/npm-publish.log; then echo "Package not yet on registry, publishing without provenance for initial seed..." npm publish --access public fi # ────────────────────────────────────────────────── # Quality Gate Summary # ────────────────────────────────────────────────── quality-gate: name: Quality Gate runs-on: ubuntu-latest needs: [lint, docs-parity, wiring-audit, openapi-coverage, security, test, contract, smoke, e2e, container-scan, sast, performance, build] if: always() steps: - name: Check all gates run: | echo "=== Quality Gate Summary ===" echo "Lint & Typecheck: ${{ needs.lint.result }}" echo "Docs Parity: ${{ needs.docs-parity.result }}" echo "Wiring Audit: ${{ needs.wiring-audit.result }}" echo "OpenAPI Coverage: ${{ needs.openapi-coverage.result }}" echo "Unit Tests: ${{ needs.test.result }}" echo "Contract Tests: ${{ needs.contract.result }}" echo "Smoke Tests: ${{ needs.smoke.result }}" echo "E2E Tests: ${{ needs.e2e.result }}" echo "Container Scan: ${{ needs.container-scan.result }}" echo "SAST: ${{ needs.sast.result }}" echo "Build: ${{ needs.build.result }}" echo "Docs Parity: ${{ needs.docs-parity.result }}" echo "Security Gates: ${{ needs.security.result }}" echo "Perf Gate: ${{ needs.performance.result }}" if [[ "${{ needs.lint.result }}" != "success" ]] || \ [[ "${{ needs.docs-parity.result }}" != "success" ]] || \ [[ "${{ needs.security.result }}" != "success" ]] || \ [[ "${{ needs.test.result }}" != "success" ]] || \ [[ "${{ needs.performance.result }}" != "success" ]] || \ [[ "${{ needs.build.result }}" != "success" ]]; then echo "::error::Quality gate FAILED — required checks did not pass" exit 1 fi if [[ "${{ needs.e2e.result }}" != "success" ]]; then echo "::warning::E2E tests failed — review Playwright report" fi if [[ "${{ needs.container-scan.result }}" != "success" ]] || \ [[ "${{ needs.sast.result }}" != "success" ]]; then echo "::warning::Security scans reported issues — review artifacts" fi echo "All quality gates PASSED"