# PR merge gate — always-on shim so branch protection can require CI. # # WHY THIS EXISTS # backend-ci.yml and frontend-ci.yml are path-filtered on `pull_request`, so a # frontend-only PR never produces "Backend CI Status Gate" and a backend-only # PR never produces "Frontend CI Status Gate". Making those job names required # contexts directly would leave cross-area PRs waiting forever on a check that # will never post. This workflow is NOT path-filtered: it always runs on every # PR to main and posts the four contexts branch protection requires. # # For each area the gate either: # * passes vacuously when that area's paths were not touched (the real # workflow legitimately did not run), or # * waits for and mirrors that area's real gate when the paths were touched. # # The per-area path lists below MUST mirror the `pull_request: paths:` blocks in # backend-ci.yml and frontend-ci.yml. If they drift, the failure is biased safe: # over-detection makes this gate wait for a real gate that never appears and # time out (a spurious block, recoverable by an admin), while the dangerous # direction — passing an area whose real gate actually failed — only happens on # under-detection, so keep these lists a faithful copy. deploy.yml continues to # wait on "Backend CI Status Gate" directly and is unaffected by this shim. # # --------------------------------------------------------------------------- # REQUIRED CONTEXT — DO NOT RENAME THE `CI Status Gate` JOB # --------------------------------------------------------------------------- # `main` branch protection requires "CI Status Gate", "PR Gate: Backend", # "PR Gate: Frontend", and "PR Gate: Workflow Contract" (exact strings). # The aggregate job at the bottom of this file is the ONLY job in the # repository that emits "CI Status Gate". # # Before this file gained that aggregate job, "CI Status Gate" was required but # emitted by nothing (the real gates are "Backend CI Status Gate" and # "Frontend CI Status Gate"), so every PR waited forever on a check that could # never post and merges proceeded via admin override. # # Renaming this job, deleting this workflow, or adding a path filter to its # `pull_request:` trigger re-breaks branch protection for EVERY PR. # scripts/check_workflow_inventory.py fails CI if you do; the full contract is # in docs/runbooks/BRANCH_PROTECTION.md. name: PR Gate on: pull_request: branches: [main] concurrency: group: pr-gate-${{ github.event.pull_request.number }} cancel-in-progress: true permissions: {} jobs: backend: permissions: contents: read checks: read actions: read name: "PR Gate: Backend" runs-on: ubuntu-latest timeout-minutes: 150 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v6 with: fetch-depth: 0 - name: Detect backend-relevant changes id: detect env: BASE_SHA: ${{ github.event.pull_request.base.sha }} HEAD_SHA: ${{ github.event.pull_request.head.sha }} run: | python3 - <<'PY' >> "$GITHUB_OUTPUT" import os, subprocess # Mirror of backend-ci.yml `pull_request: paths:` — keep in sync. PATHS = [ "frontend/src/lib/public-openapi.ts", "frontend/package-lock.json", "alembic/**", "server/**", "services/**", "shared/**", "kernel/**", "plugins/fsma/**", "Dockerfile", "railway.toml", "requirements.in", "requirements.lock", "scripts/**", "tests/**", "docs/security/route-security-manifest.json", "docs/security/release-evidence/**", ".github/workflows/backend-ci.yml", ".github/workflows/deploy.yml", ] base, head = os.environ["BASE_SHA"], os.environ["HEAD_SHA"] # Three-dot (merge-base..head) = only THIS PR's changes. Two-dot # would also include everything that landed on the base branch after # this PR diverged, so once main advances the gate over-detects and # waits for a real gate that never runs (spurious red). changed = subprocess.run( ["git", "diff", "--name-only", f"{base}...{head}"], check=True, capture_output=True, text=True, ).stdout.split() def matches(f): for p in PATHS: if p.endswith("/**"): if f.startswith(p[:-2]): return True elif f == p: return True return False hit = any(matches(f) for f in changed) print(f"changed={'true' if hit else 'false'}") PY - name: Wait for Backend CI Status Gate if: steps.detect.outputs.changed == 'true' uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 env: HEAD_SHA: ${{ github.event.pull_request.head.sha }} CHECK_NAME: "Backend CI Status Gate" with: github-token: ${{ github.token }} # "Backend CI Status Gate" is the final aggregate job of # backend-ci.yml. It is not created until every other backend job # finishes (up to 80 min under runner contention), so we poll up to # 140 minutes for it to appear and complete. The job's 150-minute # timeout remains the hard bound. script: | const { owner, repo } = context.repo; const sha = process.env.HEAD_SHA; const CHECK_NAME = process.env.CHECK_NAME; const started = Date.now(); const deadline = started + 140 * 60 * 1000; const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)); const callWithRetry = async (fn) => { let otherRetries = 0; while (Date.now() < deadline) { try { return await fn(); } catch (error) { if (Date.now() >= deadline) { throw error; } const status = error.status || error.response?.status; const headers = error.response?.headers || error.headers || {}; const remaining = headers['x-ratelimit-remaining'] || headers['X-RateLimit-Remaining']; const isRateLimit = status === 429 || remaining === '0' || ((status === 403 || status === 429) && ( remaining === '0' || headers['x-ratelimit-reset'] != null || headers['X-RateLimit-Reset'] != null || headers['retry-after'] != null || headers['Retry-After'] != null || /rate[- ]limit/i.test(error.message || '') )); if (isRateLimit) { let waitMs = 60000; const retryAfter = headers['retry-after'] || headers['Retry-After']; const resetHeader = headers['x-ratelimit-reset'] || headers['X-RateLimit-Reset']; if (retryAfter) { const sec = parseInt(retryAfter, 10); if (!isNaN(sec) && sec > 0) { waitMs = (sec + 5) * 1000; } else { const dateMs = Date.parse(retryAfter); if (!isNaN(dateMs)) { waitMs = Math.max(0, dateMs - Date.now()) + 5000; } } } else if (resetHeader) { const resetSec = parseInt(resetHeader, 10); if (!isNaN(resetSec) && resetSec > 0) { waitMs = Math.max(0, (resetSec * 1000) - Date.now()) + 5000; } } const remainingDeadline = Math.max(0, deadline - Date.now()); const sleepMs = Math.min(waitMs, remainingDeadline); core.warning(`Rate limit hit (${status || 'exhausted'}). Sleeping ${Math.round(sleepMs / 1000)}s until reset.`); await sleep(sleepMs); continue; } if (otherRetries < 3) { otherRetries++; const backoffMs = Math.min(5000 * Math.pow(2, otherRetries - 1), Math.max(0, deadline - Date.now())); core.warning(`API error (${error.message || status}). Retry ${otherRetries}/3 in ${Math.round(backoffMs / 1000)}s.`); await sleep(backoffMs); continue; } throw error; } } return null; }; let delay = 30000; let completedWithoutGate = false; while (Date.now() < deadline) { const res = await callWithRetry(() => github.rest.checks.listForRef({ owner, repo, ref: sha, check_name: CHECK_NAME, filter: 'latest', per_page: 20, }) ); if (!res) { break; } const runs = res.data?.check_runs || []; const completedRun = runs.find(r => r.status === 'completed'); if (completedRun) { if (completedRun.conclusion === 'success') { core.info(`Check ${CHECK_NAME} succeeded with conclusion: success`); return; } core.setFailed(completedRun.conclusion); return; } const workflowRes = await callWithRetry(() => github.rest.actions.listWorkflowRuns({ owner, repo, workflow_id: 'backend-ci.yml', head_sha: sha, event: 'pull_request', per_page: 20, }) ); if (!workflowRes) { break; } const workflowRuns = workflowRes.data?.workflow_runs || []; if (workflowRuns.length === 0 && Date.now() - started >= 10 * 60 * 1000) { core.setFailed('Backend paths changed but Backend CI was not triggered within 10 minutes; path lists in pr-gate.yml and backend-ci.yml may have drifted.'); return; } if (runs.length === 0 && workflowRuns.length > 0 && workflowRuns.every(r => r.status === 'completed')) { if (completedWithoutGate) { core.setFailed(`Backend CI completed without ${CHECK_NAME} check run. Run conclusion: ${workflowRuns.map(r => r.conclusion || 'unknown').join(', ')}`); return; } completedWithoutGate = true; core.info(`Backend CI completed (${workflowRuns.map(r => r.conclusion || 'unknown').join(', ')}); rechecking for ${CHECK_NAME}.`); } else { completedWithoutGate = false; core.info(`Backend CI run status: ${workflowRuns.length ? workflowRuns.map(r => r.status).join(', ') : 'not triggered yet'}.`); } const remaining = Math.max(0, deadline - Date.now()); if (remaining === 0) { break; } const sleepDuration = Math.min(delay, remaining); core.info(`Check ${CHECK_NAME} not completed yet. Sleeping ${Math.round(sleepDuration / 1000)}s...`); await sleep(sleepDuration); delay = Math.min(Math.round(delay * 1.5), 120000); } core.setFailed(`Timed out waiting for ${CHECK_NAME}`); - name: No backend-relevant changes if: steps.detect.outputs.changed != 'true' run: echo "No backend-relevant paths changed; Backend CI legitimately did not run. Gate vacuously green." frontend: permissions: contents: read checks: read actions: read name: "PR Gate: Frontend" runs-on: ubuntu-latest # Short bound: when the runner queue is backed up the Status Gate may not # exist yet. pr-gate-frontend-rerun.yml re-runs this job when Frontend # CI/CD completes, so a timeout here is recovered automatically. timeout-minutes: 15 concurrency: group: pr-gate-frontend-${{ github.event.pull_request.number }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v6 with: fetch-depth: 0 - name: Detect frontend-relevant changes id: detect env: BASE_SHA: ${{ github.event.pull_request.base.sha }} HEAD_SHA: ${{ github.event.pull_request.head.sha }} run: | python3 - <<'PY' >> "$GITHUB_OUTPUT" import os, subprocess # Mirror of frontend-ci.yml `pull_request: paths:` — keep in sync. PATHS = [ "frontend/**", "docs/**", "sales/**", "sales_enablement/**", "frontend/public/**", "services/admin/app/auth/**", "services/admin/app/e2e_safety.py", "services/admin/app/session_store.py", "services/admin/app/rate_limit_service.py", "services/admin/app/sqlalchemy_models.py", "services/admin/tests/test_e2e_safety.py", "services/admin/tests/test_webauthn_router.py", "scripts/seed_passwordless_e2e_user.py", "scripts/generate_openapi.py", "scripts/start-e2e-backend.sh", "requirements.in", "requirements.lock", "scripts/check_trust_safety_copy.py", "scripts/trust_safety_copy_allowlist.txt", ".github/workflows/frontend-ci.yml", ] base, head = os.environ["BASE_SHA"], os.environ["HEAD_SHA"] # Three-dot (merge-base..head) = only THIS PR's changes. Two-dot # would also include everything that landed on the base branch after # this PR diverged, so once main advances the gate over-detects and # waits for a real gate that never runs (spurious red). changed = subprocess.run( ["git", "diff", "--name-only", f"{base}...{head}"], check=True, capture_output=True, text=True, ).stdout.split() def matches(f): for p in PATHS: if p.endswith("/**"): if f.startswith(p[:-2]): return True elif f == p: return True return False hit = any(matches(f) for f in changed) print(f"changed={'true' if hit else 'false'}") PY - name: Wait for Frontend CI Status Gate if: steps.detect.outputs.changed == 'true' uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 env: HEAD_SHA: ${{ github.event.pull_request.head.sha }} CHECK_NAME: "Frontend CI Status Gate" with: github-token: ${{ github.token }} # "Frontend CI Status Gate" is created only after earlier frontend # jobs finish. A gate that is missing or still queued is pending, not # failed: we poll up to 12 minutes, then fail with an explicit # "still pending" message. pr-gate-frontend-rerun.yml re-runs this job # once Frontend CI/CD completes, which then mirrors the real result. script: | const { owner, repo } = context.repo; const sha = process.env.HEAD_SHA; const CHECK_NAME = process.env.CHECK_NAME; const deadline = Date.now() + 12 * 60 * 1000; const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)); const callWithRetry = async (fn) => { let otherRetries = 0; while (Date.now() < deadline) { try { return await fn(); } catch (error) { if (Date.now() >= deadline) { throw error; } const status = error.status || error.response?.status; const headers = error.response?.headers || error.headers || {}; const remaining = headers['x-ratelimit-remaining'] || headers['X-RateLimit-Remaining']; const isRateLimit = status === 429 || remaining === '0' || ((status === 403 || status === 429) && ( remaining === '0' || headers['x-ratelimit-reset'] != null || headers['X-RateLimit-Reset'] != null || headers['retry-after'] != null || headers['Retry-After'] != null || /rate[- ]limit/i.test(error.message || '') )); if (isRateLimit) { let waitMs = 60000; const retryAfter = headers['retry-after'] || headers['Retry-After']; const resetHeader = headers['x-ratelimit-reset'] || headers['X-RateLimit-Reset']; if (retryAfter) { const sec = parseInt(retryAfter, 10); if (!isNaN(sec) && sec > 0) { waitMs = (sec + 5) * 1000; } else { const dateMs = Date.parse(retryAfter); if (!isNaN(dateMs)) { waitMs = Math.max(0, dateMs - Date.now()) + 5000; } } } else if (resetHeader) { const resetSec = parseInt(resetHeader, 10); if (!isNaN(resetSec) && resetSec > 0) { waitMs = Math.max(0, (resetSec * 1000) - Date.now()) + 5000; } } const remainingDeadline = Math.max(0, deadline - Date.now()); const sleepMs = Math.min(waitMs, remainingDeadline); core.warning(`Rate limit hit (${status || 'exhausted'}). Sleeping ${Math.round(sleepMs / 1000)}s until reset.`); await sleep(sleepMs); continue; } if (otherRetries < 3) { otherRetries++; const backoffMs = Math.min(5000 * Math.pow(2, otherRetries - 1), Math.max(0, deadline - Date.now())); core.warning(`API error (${error.message || status}). Retry ${otherRetries}/3 in ${Math.round(backoffMs / 1000)}s.`); await sleep(backoffMs); continue; } throw error; } } return null; }; let delay = 30000; while (Date.now() < deadline) { const res = await callWithRetry(() => github.rest.checks.listForRef({ owner, repo, ref: sha, check_name: CHECK_NAME, filter: 'latest', per_page: 20, }) ); if (!res) { break; } const runs = res.data?.check_runs || []; const completedRun = runs.find(r => r.status === 'completed'); if (completedRun) { if (completedRun.conclusion === 'success') { core.info(`Check ${CHECK_NAME} succeeded with conclusion: success`); return; } core.setFailed(completedRun.conclusion); return; } const remaining = Math.max(0, deadline - Date.now()); if (remaining === 0) { break; } const sleepDuration = Math.min(delay, remaining); core.info(`Check ${CHECK_NAME} not completed yet. Sleeping ${Math.round(sleepDuration / 1000)}s...`); await sleep(sleepDuration); delay = Math.min(Math.round(delay * 1.5), 120000); } core.setFailed( `${CHECK_NAME} is still pending (not created yet or queued) after 12 minutes; ` + `this is a CI queue delay, not a frontend failure. This job is re-run ` + `automatically when Frontend CI/CD completes (pr-gate-frontend-rerun.yml).` ); - name: No frontend-relevant changes if: steps.detect.outputs.changed != 'true' run: echo "No frontend-relevant paths changed; Frontend CI legitimately did not run. Gate vacuously green." # Workflow / branch-protection contract guard. Lives here rather than in # backend-ci.yml because that workflow is path-filtered: renaming a workflow # this guard is meant to catch (say qa-health.yml) touches none of its paths, # so the guard would not run on exactly the PR that breaks the contract. # pr-gate.yml is unfiltered by construction. workflow-contract: permissions: contents: read name: "PR Gate: Workflow Contract" runs-on: ubuntu-latest timeout-minutes: 5 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v6 - name: Setup Python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: '3.12' cache: pip cache-dependency-path: .github/workflows/pr-gate.yml - name: Install PyYAML run: pip install --disable-pip-version-check "pyyaml==6.0.2" - name: Check workflow inventory and required contexts run: python3 scripts/check_workflow_inventory.py # security.yml skips docs-only PRs, so keep both secret scanners in this # always-on workflow. Its aggregate gate fails if either scanner fails. secrets: permissions: contents: read name: "PR Gate: Secrets" runs-on: ubuntu-latest timeout-minutes: 30 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v6 with: fetch-depth: 0 - name: Install gitleaks CLI env: GITLEAKS_VERSION: v8.30.0 GITLEAKS_SHA256: 79a3ab579b53f71efd634f3aaf7e04a0fa0cf206b7ed434638d1547a2470a66e # pragma: allowlist secret run: | curl -sSfL "https://github.com/gitleaks/gitleaks/releases/download/${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION#v}_linux_x64.tar.gz" -o /tmp/gitleaks.tar.gz echo "${GITLEAKS_SHA256} /tmp/gitleaks.tar.gz" | sha256sum -c - tar -xzf /tmp/gitleaks.tar.gz -C /tmp gitleaks sudo install -m 0755 /tmp/gitleaks /usr/local/bin/gitleaks gitleaks version - name: Scan PR commits with gitleaks env: BASE_SHA: ${{ github.event.pull_request.base.sha }} run: | gitleaks detect \ --source . \ --config .gitleaks.toml \ --redact \ --verbose \ --log-opts="${BASE_SHA}..HEAD" - name: Setup Python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.11" cache: pip cache-dependency-path: .github/workflows/pr-gate.yml - name: Install detect-secrets run: pip install detect-secrets==1.5.0 - name: Scan for new secrets against baseline run: bash scripts/ci/check_secrets_baseline.sh # The aggregate required context. It combines the per-area, workflow # contract, and secret-scan jobs on every PR. `needs` plus explicit result # checks make a cancelled or skipped dependency fail closed. ci-status-gate: name: "CI Status Gate" runs-on: ubuntu-latest timeout-minutes: 10 needs: [backend, frontend, workflow-contract, secrets] if: always() steps: - name: Verify all gate dependencies passed env: BACKEND: ${{ needs.backend.result }} FRONTEND: ${{ needs.frontend.result }} CONTRACT: ${{ needs.workflow-contract.result }} SECRETS: ${{ needs.secrets.result }} run: | echo "PR Gate: Backend = $BACKEND" echo "PR Gate: Frontend = $FRONTEND" echo "PR Gate: Workflow Contract = $CONTRACT" echo "PR Gate: Secrets = $SECRETS" if [ "$BACKEND" != "success" ] || [ "$FRONTEND" != "success" ] || [ "$CONTRACT" != "success" ] || [ "$SECRETS" != "success" ]; then echo "::error::CI Status Gate failed — backend=$BACKEND frontend=$FRONTEND contract=$CONTRACT secrets=$SECRETS" exit 1 fi echo "All gate dependencies green." # Shared PR file detection. Required gate jobs above remain unconditional. changes: name: "PR Gate: Path Detection" runs-on: ubuntu-latest permissions: pull-requests: read outputs: quality: ${{ steps.filter.outputs.quality }} tools: ${{ steps.filter.outputs.tools }} review: ${{ steps.filter.outputs.review }} steps: - uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3 id: filter with: filters: | # PR size, commit messages, and labels apply to every changed file. quality: - '**' tools: - 'frontend/src/app/tools/**' - '.github/workflows/pr-gate.yml' # Original Review Gates pull_request paths, with its merged file. review: - 'frontend/**' - 'services/**' - 'server/**' - 'alembic/**' - 'tests/**' - 'scripts/**' - '.github/workflows/pr-gate.yml' # Jobs migrated from PR Quality Gate. # ── PR Size Check ─────────────────────────────────────── pr-size: needs: changes permissions: contents: read pull-requests: read name: PR Size Check runs-on: ubuntu-latest timeout-minutes: 15 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v6 if: needs.changes.outputs.quality == 'true' with: fetch-depth: 0 - name: Check PR size if: needs.changes.outputs.quality == 'true' run: | ADDITIONS=$(gh pr view ${{ github.event.pull_request.number }} --json additions --jq '.additions') DELETIONS=$(gh pr view ${{ github.event.pull_request.number }} --json deletions --jq '.deletions') TOTAL=$((ADDITIONS + DELETIONS)) FILES=$(gh pr view ${{ github.event.pull_request.number }} --json files --jq '.files | length') echo "### 📊 PR Size" >> $GITHUB_STEP_SUMMARY echo "- **Lines changed:** +$ADDITIONS / -$DELETIONS ($TOTAL total)" >> $GITHUB_STEP_SUMMARY echo "- **Files changed:** $FILES" >> $GITHUB_STEP_SUMMARY if [ "$TOTAL" -gt 1000 ]; then echo "::warning::Large PR detected ($TOTAL lines). Consider splitting into smaller PRs." echo "- ⚠️ **Large PR** — consider splitting" >> $GITHUB_STEP_SUMMARY else echo "- ✅ Size is manageable" >> $GITHUB_STEP_SUMMARY fi env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: No relevant changes if: needs.changes.outputs.quality != 'true' run: echo 'No relevant paths changed; check passes.' # ── Conventional Commits ──────────────────────────────── commit-lint: needs: changes permissions: contents: read name: Commit Message Check runs-on: ubuntu-latest timeout-minutes: 15 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v6 if: needs.changes.outputs.quality == 'true' with: fetch-depth: 0 - name: Check commit messages if: needs.changes.outputs.quality == 'true' run: | echo "### 📝 Commit Messages" >> $GITHUB_STEP_SUMMARY # --no-merges: merge commits ("Merge branch 'main' into …") are not # conventional-commit shaped, and a branch-updated PR must not fail # this gate because of them. COMMITS=$(git log --no-merges --format="%s" origin/main..HEAD) VALID=true while IFS= read -r msg; do [ -z "$msg" ] && continue if echo "$msg" | grep -qE "^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\(.+\))?!?:"; then echo "- ✅ \`$msg\`" >> $GITHUB_STEP_SUMMARY else echo "- ⚠️ \`$msg\` — not conventional" >> $GITHUB_STEP_SUMMARY VALID=false fi done <<< "$COMMITS" if [ "$VALID" = false ]; then echo "" >> $GITHUB_STEP_SUMMARY echo "> 💡 Use [Conventional Commits](https://www.conventionalcommits.org/) format: \`type(scope): description\`" >> $GITHUB_STEP_SUMMARY # CONTRIBUTING.md:47 states conventional commits are required. This # step previously computed VALID and discarded it, so the check # could never fail despite continue-on-error: false. echo "::error::One or more commit messages are not Conventional Commits (see CONTRIBUTING.md)." exit 1 fi continue-on-error: false - name: No relevant changes if: needs.changes.outputs.quality != 'true' run: echo 'No relevant paths changed; check passes.' # ── Copy Lint: Ban A-F Grade / Verdict UIs in /tools ──── copy-lint-tools-grade-verdicts: needs: changes permissions: contents: read name: Copy Lint — ban A-F grade/verdict UIs in /tools runs-on: ubuntu-latest timeout-minutes: 15 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v6 if: needs.changes.outputs.tools == 'true' - name: Grep for banned grade/verdict copy in frontend/src/app/tools if: needs.changes.outputs.tools == 'true' run: | set -o pipefail SCOPE="frontend/src/app/tools" if [ ! -d "$SCOPE" ]; then echo "Scope directory $SCOPE missing; skipping." exit 0 fi PATTERN='Compliance Score|Compliance Grade|Get Your.*Compliance Report|Grade your ability' echo "### 🚫 Banned grade/verdict copy in $SCOPE" >> "$GITHUB_STEP_SUMMARY" HITS=$(grep -rnE "$PATTERN" "$SCOPE" --include='*.tsx' --include='*.ts' || true) if [ -n "$HITS" ]; then echo "Banned phrases found in $SCOPE:" echo "$HITS" echo '```' >> "$GITHUB_STEP_SUMMARY" echo "$HITS" >> "$GITHUB_STEP_SUMMARY" echo '```' >> "$GITHUB_STEP_SUMMARY" echo "" >> "$GITHUB_STEP_SUMMARY" echo "Self-check tools must not present A-F grades or pass/fail verdicts. See issue #2314." >> "$GITHUB_STEP_SUMMARY" exit 1 fi echo "- ✅ No banned grade/verdict phrases found" >> "$GITHUB_STEP_SUMMARY" - name: No relevant changes if: needs.changes.outputs.tools != 'true' run: echo 'No relevant paths changed; check passes.' # ── Label PR ──────────────────────────────────────────── label: needs: changes permissions: contents: read pull-requests: write name: Auto-Label PR runs-on: ubuntu-latest timeout-minutes: 10 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v6 if: needs.changes.outputs.quality == 'true' - name: Label by files changed if: needs.changes.outputs.quality == 'true' uses: actions/labeler@bf12e9b00b37c5c0ca2b87b79b2daf7891dbda13 # v7.0.0 with: github-token: ${{ secrets.GITHUB_TOKEN }} continue-on-error: false - name: No relevant changes if: needs.changes.outputs.quality != 'true' run: echo 'No relevant paths changed; check passes.' # Jobs migrated from Review Gates. # ────────────────────────────────────────────── # Supabase Query Patterns (Checklist §2, §3) # ────────────────────────────────────────────── supabase-patterns: needs: changes permissions: contents: read name: Supabase Query Patterns runs-on: ubuntu-latest timeout-minutes: 15 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v6 if: needs.changes.outputs.review == 'true' - name: Audit .single() usage if: needs.changes.outputs.review == 'true' run: | MATCHES=$(grep -rn '\.single()' frontend/src/ --include="*.ts" --include="*.tsx" 2>/dev/null || true) if [ -n "$MATCHES" ]; then echo "$MATCHES" echo "::warning::Found .single() — verify each should not be .maybeSingle()" echo " .single() throws on 0 or 2+ rows. Use .maybeSingle() when the row might not exist." fi - name: Audit getSession vs getUser if: needs.changes.outputs.review == 'true' run: | # Flag getSession in middleware or server components — should use getUser MATCHES=$(grep -rn 'getSession\b' frontend/src/middleware.ts frontend/src/app/ --include="*.ts" --include="*.tsx" 2>/dev/null || true) if [ -n "$MATCHES" ]; then echo "$MATCHES" echo "::warning::Found getSession() in server code — use getUser() for auth validation" echo " getSession() reads from the cookie and can be spoofed. getUser() validates against the auth server." fi - name: Check for select('*') if: needs.changes.outputs.review == 'true' run: | COUNT=$(grep -rn "select('\*')\|select(\"\*\")" frontend/src/ --include="*.ts" --include="*.tsx" 2>/dev/null | wc -l) if [ "$COUNT" -gt 0 ]; then grep -rn "select('\*')\|select(\"\*\")" frontend/src/ --include="*.ts" --include="*.tsx" 2>/dev/null echo "::warning::Found $COUNT select('*') calls — consider selecting only needed columns" fi - name: Check for missing error handling on Supabase calls if: needs.changes.outputs.review == 'true' run: | MATCHES=$(grep -rn 'const { data } = await supabase\|const {data} = await supabase' \ frontend/src/ --include="*.ts" --include="*.tsx" 2>/dev/null || true) if [ -n "$MATCHES" ]; then echo "$MATCHES" echo "::error::Found Supabase calls that don't destructure { error } — always check for errors" exit 1 fi - name: No relevant changes if: needs.changes.outputs.review != 'true' run: echo 'No relevant paths changed; check passes.' # ────────────────────────────────────────────── # Source Code Hygiene (Checklist §1, §5) # ────────────────────────────────────────────── source-hygiene: needs: changes permissions: contents: read name: Source Hygiene runs-on: ubuntu-latest timeout-minutes: 15 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v6 if: needs.changes.outputs.review == 'true' - name: No secrets or placeholders in source if: needs.changes.outputs.review == 'true' run: | FOUND=0 # Check frontend source if grep -rn 'sk_test_\|sk_live_\|your-api-key\|your_password' \ frontend/src/ --include="*.ts" --include="*.tsx" --include="*.js" --include="*.jsx" 2>/dev/null; then echo "::error::Found potential secrets or placeholder values in frontend source" FOUND=1 fi # Check backend source (exclude test files — they use fake keys for mocking) BACKEND_MATCHES=$(grep -rn 'sk_test_\|sk_live_\|your-api-key\|your_password' \ services/ --include="*.py" 2>/dev/null \ | grep -v '/tests/\|test_\|conftest\|_test\.py' || true) if [ -n "$BACKEND_MATCHES" ]; then echo "$BACKEND_MATCHES" echo "::error::Found potential secrets or placeholder values in backend source" FOUND=1 fi # Check for NEXT_PUBLIC_ env vars that shouldn't be public if grep -rn 'NEXT_PUBLIC_.*SERVICE_ROLE\|NEXT_PUBLIC_.*SECRET' \ frontend/src/ --include="*.ts" --include="*.tsx" 2>/dev/null; then echo "::error::Found service_role or secret key exposed via NEXT_PUBLIC_" FOUND=1 fi exit $FOUND - name: dangerouslySetInnerHTML audit if: needs.changes.outputs.review == 'true' run: | MATCHES=$(grep -rn "dangerouslySetInnerHTML" frontend/src/ --include="*.tsx" --include="*.jsx" 2>/dev/null || true) if [ -n "$MATCHES" ]; then echo "$MATCHES" echo "::warning::Found dangerouslySetInnerHTML usage — verify XSS safety (JSON.stringify is OK, user content is not)" fi - name: Check TODO/FIXME count if: needs.changes.outputs.review == 'true' run: | COUNT=$(grep -rn 'TODO\|FIXME' frontend/src/ services/ --include="*.ts" --include="*.tsx" --include="*.py" 2>/dev/null | wc -l) echo "Found $COUNT TODO/FIXME comments" if [ "$COUNT" -gt 20 ]; then echo "::warning::$COUNT unresolved TODOs across frontend + backend" fi - name: No relevant changes if: needs.changes.outputs.review != 'true' run: echo 'No relevant paths changed; check passes.' # ────────────────────────────────────────────── # Audit Trail Protection (Checklist §6) # ────────────────────────────────────────────── audit-trail: needs: changes permissions: contents: read name: Audit Trail Protection runs-on: ubuntu-latest timeout-minutes: 15 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v6 if: needs.changes.outputs.review == 'true' with: # Issue #2810: migration gates diff against origin/main; the default # shallow clone leaves origin/main absent and the diff silently no-ops. fetch-depth: 0 - name: No mutations on append-only tables (frontend) if: needs.changes.outputs.review == 'true' run: | AUDIT_TABLES="audit_logs\|pcos_audit_events\|pcos_task_events\|compliance_status_log\|evidence_logs" MATCHES=$(grep -rn '\.update(\|\.delete(\|\.upsert(' frontend/src/ --include="*.ts" --include="*.tsx" 2>/dev/null | grep -i "$AUDIT_TABLES" || true) if [ -n "$MATCHES" ]; then echo "$MATCHES" echo "::error::Found UPDATE/DELETE/UPSERT on audit trail table in frontend — audit tables must be append-only" exit 1 fi - name: No mutations on append-only tables (backend) if: needs.changes.outputs.review == 'true' run: | # Check Python services for UPDATE/DELETE on audit tables AUDIT_TABLES="audit_logs\|pcos_audit_events\|pcos_task_events\|compliance_status_log\|evidence_logs" MATCHES=$(grep -rn '\.update(\|\.delete(\|DELETE FROM\|UPDATE.*SET' services/ --include="*.py" 2>/dev/null | grep -i "$AUDIT_TABLES" || true) if [ -n "$MATCHES" ]; then echo "$MATCHES" echo "::error::Found UPDATE/DELETE on audit trail table in backend — audit tables must be append-only" exit 1 fi - name: No compliance snapshot mutations if: needs.changes.outputs.review == 'true' run: | SNAPSHOT_TABLES="compliance_snapshot\|pcos_compliance_snapshot" # Frontend FE=$(grep -rn '\.update(\|\.delete(\|\.upsert(' frontend/src/ --include="*.ts" --include="*.tsx" 2>/dev/null | grep -i "$SNAPSHOT_TABLES" || true) # Backend BE=$(grep -rn '\.update(\|\.delete(\|UPDATE.*SET\|DELETE FROM' services/ --include="*.py" 2>/dev/null | grep -i "$SNAPSHOT_TABLES" || true) if [ -n "$FE" ] || [ -n "$BE" ]; then echo "${FE}${BE}" echo "::error::Found mutation on compliance snapshot table — snapshots must be immutable (new snapshot = new row)" exit 1 fi - name: No destructive migrations on audit tables if: needs.changes.outputs.review == 'true' run: | # Issue #2810: migrations live in alembic/versions/*.py (Python), not # migrations/*.sql. A missing origin/main must fail loudly, not no-op. git rev-parse --verify origin/main >/dev/null # Only check migration files changed in this PR (not all historical migrations) CHANGED_MIGRATIONS=$(git diff --name-only origin/main...HEAD -- 'alembic/versions/*.py') if [ -z "$CHANGED_MIGRATIONS" ]; then echo "No migration files changed — skipping" exit 0 fi # Destructive Alembic operations, plus raw DDL inside op.execute(...). # Matched per-LINE so the destructive op and the audit table have to be # the same statement — a file-wide AND flags almost every migration. DESTRUCTIVE_RE='op\.drop_table|op\.drop_column|op\.drop_constraint|DROP +TABLE|DROP +COLUMN|TRUNCATE' AUDIT_RE='audit|evidence_log|compliance_status_log|task_event' # Statements that merely contain a destructive keyword but destroy no data. BENIGN_RE='REVOKE|DROP +TRIGGER|DROP +POLICY|DROP +INDEX|^[0-9]+:[[:space:]]*#' # Blank out the downgrade() body — a downgrade dropping what its own # upgrade created is expected. Lines are blanked, not deleted, so the # line numbers reported below still match the real file. Helper # functions defined after downgrade() are still scanned. # Keep awk's $0 literal for awk, not shell expansion. # shellcheck disable=SC2016 UPGRADE_ONLY='/^def downgrade/{d=1} d && /^[A-Za-z@_]/ && !/^def downgrade/{d=0} {print (d ? "" : $0)}' FAILED=0 for f in $CHANGED_MIGRATIONS; do [ -f "$f" ] || continue HITS=$(awk "$UPGRADE_ONLY" "$f" | grep -Eni "$DESTRUCTIVE_RE" | grep -Ei "$AUDIT_RE" | grep -Eiv "$BENIGN_RE" || true) if [ -n "$HITS" ]; then echo "$f:" echo "$HITS" FAILED=1 fi done if [ "$FAILED" -eq 1 ]; then echo "::error::Found destructive migration on audit tables — requires manual review" exit 1 fi - name: No relevant changes if: needs.changes.outputs.review != 'true' run: echo 'No relevant paths changed; check passes.' # ────────────────────────────────────────────── # SQL Injection Defense (Checklist §5 — backend) # ────────────────────────────────────────────── sql-safety: needs: changes permissions: contents: read name: SQL Injection Defense runs-on: ubuntu-latest timeout-minutes: 15 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v6 if: needs.changes.outputs.review == 'true' - name: No f-string SQL in Python services if: needs.changes.outputs.review == 'true' run: | # Flag f-strings containing SQL keywords — high-confidence pattern for SQL injection MATCHES=$(grep -rn 'f".*SELECT\|f".*INSERT\|f".*UPDATE\|f".*DELETE\|f".*DROP' \ services/ --include="*.py" 2>/dev/null || true) # Filter out comments and test files FILTERED=$(echo "$MATCHES" | grep -v '^\s*#\|test_\|_test\.py\|conftest' || true) if [ -n "$FILTERED" ]; then echo "$FILTERED" echo "::error::Found f-string SQL in Python services — use parameterized queries via text() with bound params" exit 1 fi - name: No relevant changes if: needs.changes.outputs.review != 'true' run: echo 'No relevant paths changed; check passes.' # ────────────────────────────────────────────── # Tenant Isolation (Checklist §7) # ────────────────────────────────────────────── tenant-isolation: needs: changes permissions: contents: read name: Tenant Isolation Check runs-on: ubuntu-latest timeout-minutes: 15 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v6 if: needs.changes.outputs.review == 'true' with: # Issue #2810: migration gates diff against origin/main; the default # shallow clone leaves origin/main absent and the diff silently no-ops. fetch-depth: 0 - name: New migrations include tenant_id where expected if: needs.changes.outputs.review == 'true' run: | # Issue #2810: migrations are alembic/versions/*.py. In Alembic the table # name usually sits on the line AFTER op.create_table(, so match the # operation and the quoted table name independently. git rev-parse --verify origin/main >/dev/null CHANGED_MIGRATIONS=$(git diff --name-only origin/main...HEAD -- 'alembic/versions/*.py') CREATE_RE='op\.create_table\(|CREATE TABLE' SCOPED_TABLE_RE="[\"'](fsma\.)?(pcos_|compliance_|evidence_)[a-z0-9_]*[\"']|CREATE TABLE +[a-z0-9_.\"']*(pcos_|compliance_|evidence_)" for f in $CHANGED_MIGRATIONS; do [ -f "$f" ] || continue if grep -Eq "$CREATE_RE" "$f" && grep -Eqi "$SCOPED_TABLE_RE" "$f"; then if ! grep -q "tenant_id" "$f"; then echo "::error::Migration $f creates a compliance/pcos table without tenant_id column" exit 1 fi fi done - name: New migrations enable RLS on new tables if: needs.changes.outputs.review == 'true' run: | # Issue #2810: RLS is turned on from Alembic via # op.execute("ALTER TABLE ... ENABLE ROW LEVEL SECURITY"). git rev-parse --verify origin/main >/dev/null CHANGED_MIGRATIONS=$(git diff --name-only origin/main...HEAD -- 'alembic/versions/*.py') CREATE_RE='op\.create_table\(|CREATE TABLE' for f in $CHANGED_MIGRATIONS; do [ -f "$f" ] || continue # Repairs to historical data backfills do not create tables. # Check newly added create operations, including additions to an # existing migration, instead of every old operation in the file. ADDED=$(git diff --unified=0 origin/main...HEAD -- "$f" | grep '^+[^+]' || true) if echo "$ADDED" | grep -Eq "$CREATE_RE"; then if ! grep -qi "ENABLE ROW LEVEL SECURITY" "$f"; then echo "::error::Migration $f creates a table without enabling RLS — add op.execute(\"ALTER TABLE ... ENABLE ROW LEVEL SECURITY\")" exit 1 fi fi done - name: Flag direct pcos table access without tenant filter if: needs.changes.outputs.review == 'true' run: | PCOS_CALLS=$(grep -rn "\.from(['\"]pcos_" frontend/src/ --include="*.ts" --include="*.tsx" 2>/dev/null || true) if [ -n "$PCOS_CALLS" ]; then echo "$PCOS_CALLS" echo "::warning::Found direct pcos_* table access — verify tenant_id filtering is applied (via RLS or explicit .eq())" fi - name: No relevant changes if: needs.changes.outputs.review != 'true' run: echo 'No relevant paths changed; check passes.' # ────────────────────────────────────────────── # React Patterns (Checklist §4) # ────────────────────────────────────────────── react-patterns: needs: changes permissions: contents: read name: React Patterns runs-on: ubuntu-latest timeout-minutes: 15 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v6 if: needs.changes.outputs.review == 'true' - name: Check for useEffect async patterns if: needs.changes.outputs.review == 'true' run: | # Rough heuristic: useEffect with [] and await inside (potential stale closure) if grep -rn -A8 'useEffect.*\[\]' frontend/src/ --include="*.tsx" --include="*.ts" 2>/dev/null | grep -q 'await\|\.then('; then echo "::warning::Found useEffect with empty deps [] containing async calls — verify no stale data issues" fi - name: Check for setState during render if: needs.changes.outputs.review == 'true' run: | # Flag obvious cases: setState outside useEffect/callbacks at component top level # This is a rough heuristic — manual review still needed MATCHES=$(grep -rn 'useState\|setState' frontend/src/ --include="*.tsx" 2>/dev/null | wc -l) echo "Found $MATCHES state-related patterns (manual review for render-phase setState)" - name: No relevant changes if: needs.changes.outputs.review != 'true' run: echo 'No relevant paths changed; check passes.'