CI #1022
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
| # PCMI CI: lint → unit/integration (race) → smoke → optional OpenAI E2E | |
| # Coverage: sticky PR comment (refreshed after *every* push), job summary, artifacts | |
| # (see scripts/ci_coverage_check.sh + the "Resolve PR for coverage comment" step). | |
| # This design prevents stale coverage numbers on long-lived feature PRs. | |
| # | |
| # ── Job DAG (needs →) ───────────────────────────────────────────────────────── | |
| # Wave 1 (parallel): | |
| # golangci-lint | security | helm-lint | trivy-images | go | |
| # Wave 2 (after Wave 1 subsets): | |
| # integration-smoke → needs [go, golangci-lint, security, trivy-images] | |
| # integration-e2e → needs [go, golangci-lint] (skips if no OPENAI_API_KEY; no retry) | |
| # integration-graph → needs [go, golangci-lint] (complete cognitive graph matrix gate) | |
| # CodeQL: separate workflow (.github/workflows/codeql.yml) | |
| # Local parity: make ci-like-github → scripts/ci_like_github.sh (phases A–G) | |
| # Version for smoke: scripts/ci/resolve_version.sh (internal/version/version.go) | |
| name: CI | |
| on: | |
| push: | |
| branches: | |
| - main | |
| - release/** | |
| - develop | |
| - feat/** | |
| - feature/** | |
| # Skipped only when every changed file matches (badge-only pushes on main). | |
| paths-ignore: | |
| - 'badges/**' | |
| pull_request: | |
| branches: | |
| - main | |
| - release/** | |
| - develop | |
| - feat/** | |
| - feature/** | |
| workflow_dispatch: | |
| schedule: | |
| # Daily bug-hunt on main — runs non-redundant phases (nilaway, gosec, | |
| # staticcheck, fuzz, api_property, migrations, containers). | |
| - cron: '37 4 * * *' | |
| concurrency: | |
| group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} | |
| cancel-in-progress: true | |
| permissions: | |
| contents: read | |
| pull-requests: write | |
| jobs: | |
| golangci-lint: | |
| name: golangci-lint | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 15 | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - uses: actions/setup-go@v7 | |
| with: | |
| go-version-file: go.mod | |
| cache: true | |
| - name: golangci-lint | |
| uses: golangci/golangci-lint-action@v9 | |
| with: | |
| version: v2.12.2 | |
| - name: nilaway (nil-deref detection) | |
| run: | | |
| go install go.uber.org/nilaway/cmd/nilaway@latest | |
| nilaway ./... || true | |
| - name: gosec (security lint) | |
| run: | | |
| go install github.com/securego/gosec/v2/cmd/gosec@latest | |
| gosec -quiet -severity medium -confidence medium ./... || true | |
| security: | |
| name: Security scan (govulncheck) | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 10 | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - uses: actions/setup-go@v7 | |
| with: | |
| go-version-file: go.mod | |
| cache: true | |
| - name: govulncheck | |
| run: scripts/ci/phases/c_govulncheck.sh | |
| helm-lint: | |
| name: Helm chart lint | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 10 | |
| permissions: | |
| contents: read | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - name: Helm + kubeconform validation | |
| run: scripts/ci/phases/d_helm.sh | |
| trivy-images: | |
| name: Trivy image scan (API + Worker) | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 30 | |
| permissions: | |
| contents: read | |
| steps: | |
| - name: Skip notice (act only) | |
| if: ${{ env.ACT == 'true' }} | |
| run: echo "::notice::trivy-images skipped under act; run 'make act-trivy' locally" | |
| - uses: actions/checkout@v7 | |
| if: ${{ env.ACT != 'true' }} | |
| - name: Set up Docker Buildx | |
| if: ${{ env.ACT != 'true' }} | |
| uses: docker/setup-buildx-action@v4 | |
| - name: Build and scan images (Trivy) | |
| if: ${{ env.ACT != 'true' }} | |
| run: | | |
| scripts/ci/trivy_images.sh | |
| go: | |
| name: Go build, vet & test + coverage | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 45 | |
| permissions: | |
| contents: write | |
| pull-requests: write | |
| services: | |
| postgres: | |
| image: pgvector/pgvector:pg16 | |
| env: | |
| POSTGRES_DB: pcmi | |
| POSTGRES_USER: pcmi | |
| POSTGRES_PASSWORD: pcmi | |
| ports: | |
| - 5432:5432 | |
| options: >- | |
| --health-cmd "pg_isready -U pcmi -d pcmi" | |
| --health-interval 5s | |
| --health-timeout 5s | |
| --health-retries 10 | |
| steps: | |
| - uses: actions/checkout@v7 | |
| with: | |
| persist-credentials: true | |
| token: ${{ secrets.BADGE_UPDATE_TOKEN || github.token }} | |
| - uses: actions/setup-go@v7 | |
| with: | |
| go-version-file: go.mod | |
| cache: true | |
| - name: Static checks (build, vet, config audit) | |
| run: scripts/ci/phases/a_go_static.sh | |
| - name: Install postgresql-client | |
| run: sudo apt-get update && sudo apt-get install -y postgresql-client | |
| - uses: ./.github/actions/pcmi-postgres-migrate | |
| - name: Test (race detector + integration + coverage) | |
| env: | |
| DATABASE_URL: postgres://pcmi:pcmi@127.0.0.1:5432/pcmi?sslmode=disable | |
| PCMI_ENCRYPTION_KEY: "01234567890123456789012345678901" | |
| SKIP_COVERAGE: "1" | |
| run: scripts/ci/phases/f_go_integration.sh | |
| - name: Enforce coverage thresholds | |
| id: coverage | |
| env: | |
| # Build the workflow run URL so the script can embed it in the markdown comment. | |
| COVERAGE_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} | |
| # If a previous coverage % is known (e.g. from a prior comment or artifact), the | |
| # script will show a delta line (point 8 — "change since last time"). | |
| COVERAGE_PREVIOUS_PCT: ${{ env.PREVIOUS_COVERAGE_PCT || '' }} | |
| run: | | |
| # shellcheck disable=SC1091 | |
| source scripts/ci/coverage_env.sh | |
| scripts/ci_coverage_check.sh | |
| - name: Publish coverage to job summary | |
| if: ${{ always() && hashFiles('coverage-summary.md') != '' }} | |
| run: cat coverage-summary.md >> "$GITHUB_STEP_SUMMARY" | |
| # ───────────────────────────────────────────────────────────────────────── | |
| # Coverage sticky comment — refreshed on EVERY push | |
| # | |
| # Goal: Prevent "visual staleness" on long-lived PRs. | |
| # When a big feature PR (e.g. #112) is opened, the coverage numbers are | |
| # captured at that moment. Later PRs (e.g. #115) can massively improve | |
| # coverage on packages like webhook, handler, graph, etc. | |
| # | |
| # By re-running the coverage job + sticky comment on every push to a | |
| # feat/** branch (and on pull_request events), the numbers shown in the | |
| # PR are always from the *current* code on that branch, not from the | |
| # moment the PR was first created. | |
| # | |
| # Key improvements (PR #119, points 1-8): | |
| # 1. Fork support — uses pull_request.head.repo.owner for fork PRs | |
| # 2. Workflow run link — included in the posted comment | |
| # 3. Multiple PRs — logs warning, takes the most recently updated | |
| # 4. Post on failed runs — posts with 🔴 + failure note (transparency) | |
| # 5. Global row formatting — fixed markdown via ci_coverage_check.sh | |
| # 6. Documentation — docs/ci-coverage-reporting.md | |
| # 7. Skip on main pushes — explicit early exit for main/release/** | |
| # 8. Change since last time — delta shown when COVERAGE_PREVIOUS_PCT is set | |
| # ───────────────────────────────────────────────────────────────────────── | |
| # Step A: Resolve which PR to comment on. | |
| # | |
| # Fork support (point 1): on pull_request events we read the head repo | |
| # owner from the event payload (works for both same-repo and fork PRs). | |
| # On push events we fall back to context.repo.owner and try to find an | |
| # open PR whose head branch matches. | |
| # | |
| # Skip on main/release pushes (point 7): we short-circuit when the push | |
| # target is main or release/** — no PR comment to update on those branches. | |
| - name: Resolve PR for coverage comment | |
| id: cov_pr | |
| if: ${{ always() && hashFiles('coverage-summary.md') != '' }} | |
| uses: actions/github-script@v9 | |
| with: | |
| script: | | |
| // ── Point 7: skip on main / release pushes ───────────────── | |
| const ref = context.ref || ''; | |
| const isMainPush = | |
| context.eventName === 'push' && | |
| (ref === 'refs/heads/main' || ref.startsWith('refs/heads/release/')); | |
| if (isMainPush) { | |
| core.info('Skipping coverage comment: push to main/release branch'); | |
| core.setOutput('number', ''); | |
| core.setOutput('skip', 'true'); | |
| return; | |
| } | |
| let n = ''; | |
| let forkWarning = false; | |
| // ── Point 1: fork-aware PR resolution ────────────────────── | |
| if (context.eventName === 'pull_request' && context.payload.pull_request) { | |
| // On pull_request events we have the number directly — most reliable. | |
| n = String(context.payload.pull_request.number); | |
| // Detect fork PRs: head.repo.owner differs from base.repo.owner. | |
| const headOwner = context.payload.pull_request.head?.repo?.owner?.login || ''; | |
| const baseOwner = context.payload.pull_request.base?.repo?.owner?.login || ''; | |
| if (headOwner && baseOwner && headOwner !== baseOwner) { | |
| core.info(`Fork PR detected: head=${headOwner}, base=${baseOwner}`); | |
| forkWarning = true; | |
| } | |
| } else if (context.eventName === 'push') { | |
| // On push events: find open PR(s) whose head branch matches. | |
| const branch = ref.replace('refs/heads/', ''); | |
| try { | |
| const { data: prs } = await github.rest.pulls.list({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| head: `${context.repo.owner}:${branch}`, | |
| state: 'open', | |
| sort: 'updated', | |
| direction: 'desc', | |
| per_page: 10, | |
| }); | |
| if (prs.length === 0) { | |
| // Try also searching with the fork owner pattern | |
| core.info(`No PR found for head=${context.repo.owner}:${branch}`); | |
| } else { | |
| // ── Point 3: multiple PRs → take most recent, log warning ── | |
| if (prs.length > 1) { | |
| core.warning( | |
| `Found ${prs.length} open PRs for branch ${branch}. ` + | |
| `Taking the most recently updated one (#${prs[0].number}). ` + | |
| `Full list: ${prs.map(p => `#${p.number}`).join(', ')}` | |
| ); | |
| } | |
| n = String(prs[0].number); | |
| } | |
| } catch (e) { | |
| core.warning(`Failed to resolve PR for branch ${branch}: ${e.message}`); | |
| } | |
| } | |
| // Also accept explicit PR number via workflow_dispatch inputs. | |
| if (!n && context.payload.inputs && context.payload.inputs.pr_number) { | |
| n = String(context.payload.inputs.pr_number); | |
| } | |
| core.setOutput('number', n); | |
| core.setOutput('skip', (!n) ? 'true' : 'false'); | |
| core.info(`Coverage comment target PR: ${n || '(none resolved)'}`); | |
| # Step B: Post (or update) the sticky comment. | |
| # | |
| # Point 4: we always post when we have coverage data, even when thresholds | |
| # failed. The script already sets 🔴 vs ✅ in the markdown header, so | |
| # readers immediately see whether coverage passed or failed. | |
| - name: Comment coverage on PR | |
| if: ${{ always() && hashFiles('coverage-summary.md') != '' && steps.cov_pr.outputs.number != '' && steps.cov_pr.outputs.skip != 'true' }} | |
| uses: marocchino/sticky-pull-request-comment@v3 | |
| with: | |
| header: pcmi-coverage | |
| number: ${{ steps.cov_pr.outputs.number }} | |
| path: coverage-summary.md | |
| - name: Upload coverage artifacts | |
| if: ${{ always() }} | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: coverage-${{ github.run_id }} | |
| path: | | |
| coverage.out | |
| coverage-summary.md | |
| coverage-badge.txt | |
| badges/coverage.json | |
| retention-days: 14 | |
| # Intentional one-file chore on main (not feature work). Requires Ruleset bypass | |
| # for GitHub Actions — see docs/github-branch-protection.md. | |
| # Loop prevention: on.push.paths-ignore badges/** + [skip ci] in commit message. | |
| - name: Commit dynamic coverage badge (main only) | |
| if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' && hashFiles('badges/coverage.json') != '' }} | |
| run: | | |
| if git diff --quiet -- badges/coverage.json; then | |
| echo "badge unchanged — skipping commit" | |
| exit 0 | |
| fi | |
| git config user.name "pcmi-ci[bot]" | |
| git config user.email "pcmi-ci@users.noreply.github.com" | |
| git add badges/coverage.json | |
| git commit -m "chore(ci): update coverage badge [skip ci]" \ | |
| -m "Generated by scripts/ci_coverage_check.sh on commit ${GITHUB_SHA::8}." | |
| git push origin HEAD:main | |
| test-massive: | |
| name: Massive / realistic workload tests | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 10 | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - uses: actions/setup-go@v7 | |
| with: | |
| go-version-file: go.mod | |
| cache: true | |
| - name: Run massive realistic multi-agent workload test | |
| run: | | |
| go test -tags=massive -count=1 -timeout=5m ./internal/service -run TestMassiveRealisticMultiAgentWorkload -v | |
| test-massive-live: | |
| name: Massive workload via HTTP (docker compose) | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 20 | |
| needs: [go, golangci-lint] | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - name: Set up Docker Buildx | |
| uses: docker/setup-buildx-action@v4 | |
| - name: Create .env for compose | |
| run: | | |
| cat > .env <<'ENVEOF' | |
| DATABASE_URL=postgres://pcmi:pcmi@postgres:5432/pcmi?sslmode=disable | |
| REDIS_ADDR=redis:6379 | |
| API_PORT=8000 | |
| RATE_LIMIT_DISABLED=true | |
| EMBEDDING_MODEL=text-embedding-3-small | |
| ENVEOF | |
| - name: Install tooling | |
| run: sudo apt-get update && sudo apt-get install -y jq curl | |
| - name: Start stack | |
| run: docker compose up -d --build --wait | |
| - name: Run massive curl smoke | |
| env: | |
| PCMI_BASE_URL: http://127.0.0.1:8000 | |
| PCMI_API_KEY: testkey123 | |
| run: ./scripts/ci_massive_curl_smoke.sh | |
| - name: Show compose logs on failure | |
| if: failure() | |
| run: docker compose logs --tail=200 || true | |
| - name: Cleanup | |
| if: always() | |
| run: docker compose down -v --remove-orphans | |
| integration-smoke: | |
| name: Integration smoke (Postgres + Redis) | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 30 | |
| needs: [go, golangci-lint, security, trivy-images] | |
| services: | |
| postgres: | |
| image: pgvector/pgvector:pg16 | |
| env: | |
| POSTGRES_DB: pcmi | |
| POSTGRES_USER: pcmi | |
| POSTGRES_PASSWORD: pcmi | |
| ports: | |
| - 5432:5432 | |
| options: >- | |
| --health-cmd "pg_isready -U pcmi -d pcmi" | |
| --health-interval 5s | |
| --health-timeout 5s | |
| --health-retries 10 | |
| redis: | |
| image: redis:7-alpine | |
| ports: | |
| - 6379:6379 | |
| options: >- | |
| --health-cmd "redis-cli ping" | |
| --health-interval 5s | |
| --health-timeout 5s | |
| --health-retries 10 | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - name: Skip notice (act only) | |
| if: ${{ env.ACT == 'true' }} | |
| run: echo "::notice::integration-smoke skipped under act — run 'make act-integration-smoke' locally" | |
| - uses: actions/setup-go@v7 | |
| if: ${{ env.ACT != 'true' }} | |
| with: | |
| go-version-file: go.mod | |
| cache: true | |
| - uses: ./.github/actions/pcmi-resolve-version | |
| if: ${{ env.ACT != 'true' }} | |
| id: api_version | |
| - name: Install tooling | |
| if: ${{ env.ACT != 'true' }} | |
| run: sudo apt-get update && sudo apt-get install -y postgresql-client jq curl | |
| - uses: ./.github/actions/pcmi-postgres-migrate | |
| if: ${{ env.ACT != 'true' }} | |
| - name: Build and start API + worker | |
| if: ${{ env.ACT != 'true' }} | |
| run: | | |
| scripts/ci/start_api_worker.sh | |
| - name: Wait for API health | |
| if: ${{ env.ACT != 'true' }} | |
| run: | | |
| scripts/ci/wait_api_health.sh || { | |
| cat api.log 2>/dev/null || true | |
| cat worker.log 2>/dev/null || true | |
| exit 1 | |
| } | |
| - name: Integration smoke suite (bash) | |
| if: ${{ env.ACT != 'true' }} | |
| env: | |
| PCMI_EXPECT_VERSION: ${{ steps.api_version.outputs.tag }} | |
| EXPECT_API_VERSION: ${{ steps.api_version.outputs.tag }} | |
| PGHOST: "127.0.0.1" | |
| API: "http://127.0.0.1:8000" | |
| run: ./scripts/ci_integration_smoke.sh | |
| - name: gRPC integration tests (Go) | |
| if: ${{ env.ACT != 'true' }} | |
| env: | |
| GRPC_HOST: 127.0.0.1:50051 | |
| GRPC_TEST_API_KEY: testkey123 | |
| DATABASE_URL: postgres://pcmi:pcmi@127.0.0.1:5432/pcmi?sslmode=disable | |
| run: go test -tags=integration -count=1 ./internal/grpc/... | |
| - name: SDK smoke (Python + TypeScript) | |
| if: ${{ env.ACT != 'true' }} | |
| env: | |
| PCMI_BASE_URL: http://127.0.0.1:8000 | |
| PCMI_API_KEY: testkey123 | |
| run: ./scripts/ci_sdk_smoke.sh | |
| - name: Upload smoke logs on failure | |
| if: failure() && env.ACT != 'true' | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: integration-smoke-logs-${{ github.run_id }} | |
| path: | | |
| api.log | |
| worker.log | |
| retention-days: 7 | |
| if-no-files-found: ignore | |
| - name: Show logs on failure | |
| if: failure() && env.ACT != 'true' | |
| run: | | |
| cat api.log 2>/dev/null || true | |
| cat worker.log 2>/dev/null || true | |
| - name: Stop processes | |
| if: always() && env.ACT != 'true' | |
| run: | | |
| scripts/ci/stop_api_worker.sh | |
| integration-e2e: | |
| name: Integration E2E (docker compose + OpenAI) | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 45 | |
| needs: [go, golangci-lint] | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - id: openai | |
| env: | |
| OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} | |
| run: | | |
| if [ -z "$OPENAI_API_KEY" ]; then | |
| echo "enabled=false" >> "$GITHUB_OUTPUT" | |
| echo "::notice::OPENAI_API_KEY secret not set — skipping embedding E2E" | |
| else | |
| echo "enabled=true" >> "$GITHUB_OUTPUT" | |
| fi | |
| - name: Set up Docker Buildx | |
| if: steps.openai.outputs.enabled == 'true' | |
| uses: docker/setup-buildx-action@v4 | |
| - name: Create .env for compose | |
| if: steps.openai.outputs.enabled == 'true' | |
| env: | |
| OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} | |
| run: | | |
| cat > .env <<EOF | |
| OPENAI_API_KEY=${OPENAI_API_KEY} | |
| DATABASE_URL=postgres://pcmi:pcmi@postgres:5432/pcmi | |
| REDIS_ADDR=redis:6379 | |
| API_PORT=8000 | |
| RATE_LIMIT_DISABLED=true | |
| EMBEDDING_MODEL=text-embedding-3-small | |
| DISTILLATION_MODEL=gpt-4o-mini | |
| EOF | |
| - name: Install test tooling | |
| if: steps.openai.outputs.enabled == 'true' | |
| run: sudo apt-get update && sudo apt-get install -y jq curl | |
| - name: Start stack and run E2E scripts | |
| if: steps.openai.outputs.enabled == 'true' | |
| run: | | |
| ./scripts/e2e/test_pcmi.sh | |
| ./scripts/ci_e2e_sse_dedup.sh | |
| ./scripts/ci_e2e_finale.sh | |
| - name: Upload compose logs on failure | |
| if: failure() && steps.openai.outputs.enabled == 'true' | |
| run: docker compose logs --tail=200 > compose-failure.log || true | |
| - name: Publish compose failure log | |
| if: failure() && steps.openai.outputs.enabled == 'true' && hashFiles('compose-failure.log') != '' | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: integration-e2e-logs-${{ github.run_id }} | |
| path: compose-failure.log | |
| retention-days: 7 | |
| - name: Show compose logs on failure | |
| if: failure() && steps.openai.outputs.enabled == 'true' | |
| run: docker compose logs --tail=100 | |
| - name: Cleanup compose | |
| if: always() && steps.openai.outputs.enabled == 'true' | |
| run: docker compose down -v --remove-orphans | |
| integration-graph: | |
| name: Integration graph (complete AGE matrix) | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 60 | |
| needs: [go, golangci-lint] | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - uses: actions/setup-go@v7 | |
| with: | |
| go-version-file: go.mod | |
| cache: true | |
| - name: Install graph matrix tooling | |
| run: sudo apt-get update && sudo apt-get install -y jq curl | |
| - name: Complete cognitive graph matrix | |
| env: | |
| REALISTIC_SMOKE_LIMIT: "180" | |
| run: | | |
| make test-cognitive-graph-matrix | |
| - name: Upload graph matrix logs on failure | |
| if: failure() | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: graph-matrix-logs-${{ github.run_id }} | |
| path: | | |
| /tmp/graph-matrix-*.log | |
| /tmp/graph-realistic-load-smoke.log | |
| retention-days: 7 | |
| if-no-files-found: ignore | |
| # ─────────────────────────────────────────────────────────────────────────── | |
| # Bug-hunt — non-redundant phases only (daily + manual + commit tag) | |
| # | |
| # Triggers: | |
| # • schedule (daily cron on main) | |
| # • workflow_dispatch (gh workflow run CI --ref <branch>) | |
| # • push to any tracked branch when the commit message contains [bug-hunt] | |
| # • pull_request when the PR head commit contains [bug-hunt] | |
| # (pull_request payloads lack head_commit.message in `if:` — see bug-hunt-tag-check) | |
| # | |
| # Add [bug-hunt] to a commit message to run bug-hunt on push without waiting | |
| # for the daily cron. Example: git commit -m "fix: foo [bug-hunt]" | |
| # | |
| # Skipped on ordinary PR/push (no tag) because it needs Postgres + Redis and | |
| # takes ~15-20 minutes. | |
| # | |
| # Phases skipped (already covered by other CI jobs): | |
| # go vet, golangci-lint, govulncheck → golangci-lint + security jobs | |
| # unit (test -race -cover) → go job | |
| # integration → integration-smoke job | |
| # trivy image → trivy-images job | |
| # helm lint → helm-lint job | |
| # | |
| # Phases run here (NOT covered elsewhere): | |
| # staticcheck, errcheck, gosec, nilaway, gitleaks, buf, hadolint | |
| # fuzz, api_property, security (trivy fs+config), migrations, containers | |
| # ─────────────────────────────────────────────────────────────────────────── | |
| # | |
| # pull_request payloads do not expose head commit messages in job `if:` expressions, | |
| # so PRs use this lightweight gate (checks out the head commit and greps for the tag). | |
| # Push with [bug-hunt] uses github.event.head_commit.message directly in bug-hunt `if:`. | |
| bug-hunt-tag-check: | |
| name: Bug hunt tag check (PR) | |
| if: github.event_name == 'pull_request' | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 3 | |
| outputs: | |
| tagged: ${{ steps.tag.outputs.tagged }} | |
| steps: | |
| - uses: actions/checkout@v7 | |
| with: | |
| ref: ${{ github.event.pull_request.head.sha }} | |
| fetch-depth: 1 | |
| - id: tag | |
| run: | | |
| if git log -1 --format=%B | grep -qF '[bug-hunt]'; then | |
| echo "tagged=true" >> "$GITHUB_OUTPUT" | |
| echo "PR head commit contains [bug-hunt] — bug-hunt will run" | |
| else | |
| echo "tagged=false" >> "$GITHUB_OUTPUT" | |
| echo "PR head commit has no [bug-hunt] tag — skipping bug-hunt" | |
| fi | |
| bug-hunt: | |
| name: Bug hunt (daily) | |
| needs: [bug-hunt-tag-check] | |
| # Same commit can arrive via push + pull_request synchronize; dedupe by SHA. | |
| concurrency: | |
| group: bug-hunt-${{ github.sha }} | |
| cancel-in-progress: true | |
| if: >- | |
| always() && | |
| (needs.bug-hunt-tag-check.result == 'success' || needs.bug-hunt-tag-check.result == 'skipped') && | |
| ( | |
| github.event_name == 'schedule' || | |
| github.event_name == 'workflow_dispatch' || | |
| ( | |
| github.event_name == 'push' && | |
| github.event.head_commit != null && | |
| contains(github.event.head_commit.message, '[bug-hunt]') | |
| ) || | |
| ( | |
| github.event_name == 'pull_request' && | |
| needs.bug-hunt-tag-check.outputs.tagged == 'true' | |
| ) | |
| ) | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 45 | |
| permissions: | |
| contents: read | |
| security-events: write | |
| services: | |
| postgres: | |
| image: pgvector/pgvector:pg16 | |
| env: | |
| POSTGRES_DB: pcmi | |
| POSTGRES_USER: pcmi | |
| POSTGRES_PASSWORD: pcmi | |
| ports: | |
| - 5432:5432 | |
| options: >- | |
| --health-cmd "pg_isready -U pcmi -d pcmi" | |
| --health-interval 5s | |
| --health-timeout 5s | |
| --health-retries 10 | |
| redis: | |
| image: redis:7-alpine | |
| ports: | |
| - 6379:6379 | |
| options: >- | |
| --health-cmd "redis-cli ping" | |
| --health-interval 5s | |
| --health-timeout 5s | |
| --health-retries 10 | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - uses: actions/setup-go@v7 | |
| with: | |
| go-version-file: go.mod | |
| cache: true | |
| - name: Install tooling | |
| run: | | |
| # Static analysis tools | |
| go install honnef.co/go/tools/cmd/staticcheck@latest | |
| go install github.com/securego/gosec/v2/cmd/gosec@latest | |
| go install github.com/kisielk/errcheck@latest | |
| go install go.uber.org/nilaway/cmd/nilaway@latest | |
| go install github.com/zricethezav/gitleaks/v8@latest | |
| # Proto linting | |
| go install github.com/bufbuild/buf/cmd/buf@latest | |
| # Load testing | |
| go install github.com/bojand/ghz/cmd/ghz@latest | |
| go install github.com/tsenart/vegeta/v12@latest | |
| # Dockerfile lint (Haskell binary — not go-installable) | |
| curl -sSfL -o "$(go env GOPATH)/bin/hadolint" \ | |
| https://github.com/hadolint/hadolint/releases/download/v2.14.0/hadolint-linux-x86_64 | |
| chmod +x "$(go env GOPATH)/bin/hadolint" | |
| # Migration testing | |
| go install github.com/golang-migrate/migrate/v4/cmd/migrate@latest | |
| # Security | |
| curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin | |
| curl -sfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b /usr/local/bin | |
| # Python tools | |
| pip3 install --quiet --break-system-packages schemathesis 2>/dev/null || true | |
| # Add Go bin to PATH | |
| echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH" | |
| - name: Install postgresql-client | |
| run: sudo apt-get update && sudo apt-get install -y postgresql-client | |
| - uses: ./.github/actions/pcmi-postgres-migrate | |
| - name: Create .env for compose validation | |
| run: cp .env.example .env | |
| - name: Run bug-hunt (non-redundant phases) | |
| env: | |
| BUG_HUNT_CI: "1" | |
| DATABASE_URL: postgres://pcmi:pcmi@127.0.0.1:5432/pcmi?sslmode=disable | |
| REDIS_ADDR: localhost:6379 | |
| PCMI_ENCRYPTION_KEY: "01234567890123456789012345678901" | |
| RATE_LIMIT_DISABLED: "true" | |
| COVERAGE_MIN: "39" | |
| run: | | |
| chmod +x scripts/bug_hunt.sh | |
| # Phases run (all non-redundant with other CI jobs): | |
| # static — staticcheck, gosec, errcheck, nilaway, gitleaks, | |
| # buf, hadolint (skips go vet + golangci-lint in CI) | |
| # fuzz — native Go fuzz tests (30s each) | |
| # security — trivy fs + config scan (image scan is separate job) | |
| # migrations — down→up→down→up reversibility check | |
| # containers — kustomize + helm lint (helm-lint job covers too) | |
| # report — summary markdown | |
| # Skipped: | |
| # api_property — needs running API server (not started here) | |
| bash scripts/bug_hunt.sh \ | |
| --phase=static,fuzz,security,migrations,containers,report | |
| - name: Upload bug-hunt report | |
| if: always() | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: bug-hunt-report | |
| path: .bug_hunt/*/summary.md | |
| retention-days: 30 | |
| if-no-files-found: ignore |