256deb70c7
Build + Deploy ran in parallel with CI's lint/test/loc, so a deploy could ship even when CI failed. Gate Build + Deploy on CI success via workflow_run, and add per-service change detection so only affected services rebuild and only relevant lint/test jobs run on PRs. - scripts/detect-changes.sh: shared diff helper that emits per-service + aggregate flags from a BASE_SHA diff; falls back to "rebuild all" when the base is missing or unreachable - ci.yaml: detect-changes job runs first; loc-budget, *-lint, *-build, and test-* jobs gate on the relevant outputs - build-push-deploy.yml: triggered via workflow_run on CI completion; diff base is the last-build/main git tag, force-pushed by a new mark-last-build job after each green run (handles multi-commit pushes, force pushes, and the "all skipped" case) - check-loc.sh: exclude Office/binary extensions (xlsm, docx, pptx, zip, tar, gz) so binary docs aren't counted as source - loc-exceptions.txt: grandfather two existing >500 LOC files (tender_handlers.go, DecisionTreeWizard.tsx) as Phase 5+ backlog Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
391 lines
16 KiB
YAML
391 lines
16 KiB
YAML
# BreakPilot Compliance — CI Pipeline
|
|
#
|
|
# Feature branch workflow:
|
|
# feat/* | feature/* | fix/* | hotfix/* | chore/* | refactor/* | docs/* | test/* | ci/*
|
|
# → open PR targeting main
|
|
# → all jobs run as PR gates
|
|
# → squash merge to main
|
|
# → subset of jobs re-run on main to catch merge surprises
|
|
#
|
|
# Deploy is handled by build-push-deploy.yml on push to main.
|
|
|
|
name: CI
|
|
|
|
on:
|
|
push:
|
|
branches: [main]
|
|
pull_request:
|
|
branches: [main]
|
|
|
|
jobs:
|
|
|
|
# ── Change detection (always runs first) ─────────────────────────────────
|
|
# Diff base:
|
|
# PR → merge-base with the PR base branch
|
|
# push → last-build/main tag (set by build-push-deploy after a green build)
|
|
# Falls back to "rebuild all" when the base is missing or unreachable.
|
|
detect-changes:
|
|
runs-on: docker
|
|
container: alpine:3.20
|
|
outputs:
|
|
admin: ${{ steps.diff.outputs.admin }}
|
|
backend: ${{ steps.diff.outputs.backend }}
|
|
sdk: ${{ steps.diff.outputs.sdk }}
|
|
portal: ${{ steps.diff.outputs.portal }}
|
|
tts: ${{ steps.diff.outputs.tts }}
|
|
crawler: ${{ steps.diff.outputs.crawler }}
|
|
dsms_gateway: ${{ steps.diff.outputs.dsms_gateway }}
|
|
dsms_node: ${{ steps.diff.outputs.dsms_node }}
|
|
any_python: ${{ steps.diff.outputs.any_python }}
|
|
any_node: ${{ steps.diff.outputs.any_node }}
|
|
any: ${{ steps.diff.outputs.any }}
|
|
steps:
|
|
- name: Checkout
|
|
run: |
|
|
apk add --no-cache git bash
|
|
git clone --depth 200 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
|
if [ "${GITHUB_EVENT_NAME}" = "pull_request" ]; then
|
|
git fetch --depth 200 origin "${GITHUB_BASE_REF}" || true
|
|
else
|
|
git fetch --tags origin || true
|
|
fi
|
|
- name: Resolve base SHA
|
|
run: |
|
|
if [ "${GITHUB_EVENT_NAME}" = "pull_request" ]; then
|
|
BASE=$(git merge-base "origin/${GITHUB_BASE_REF}" HEAD 2>/dev/null || true)
|
|
else
|
|
BASE=$(git rev-parse --verify refs/tags/last-build/main 2>/dev/null || true)
|
|
fi
|
|
echo "Base SHA: ${BASE:-<none>}"
|
|
echo "BASE_SHA=${BASE}" >> "$GITHUB_ENV"
|
|
- name: Detect changes
|
|
id: diff
|
|
run: bash scripts/detect-changes.sh
|
|
|
|
# ── Branch naming convention (PR only) ──────────────────────────────────
|
|
branch-name:
|
|
runs-on: docker
|
|
container: alpine:3.20
|
|
if: github.event_name == 'pull_request'
|
|
steps:
|
|
- name: Validate branch name
|
|
run: |
|
|
BRANCH="${GITHUB_HEAD_REF}"
|
|
if ! echo "$BRANCH" | grep -qE '^(feat|feature|fix|hotfix|chore|refactor|docs|test|ci)/.+'; then
|
|
echo "::error::Branch '$BRANCH' does not follow naming convention."
|
|
echo "Required prefix: feat/ feature/ fix/ hotfix/ chore/ refactor/ docs/ test/ ci/"
|
|
exit 1
|
|
fi
|
|
echo "Branch name OK: $BRANCH"
|
|
|
|
# ── Guardrail integrity (PR only) ────────────────────────────────────────
|
|
guardrail-integrity:
|
|
runs-on: docker
|
|
container: alpine:3.20
|
|
if: github.event_name == 'pull_request'
|
|
steps:
|
|
- name: Checkout
|
|
run: |
|
|
apk add --no-cache git bash
|
|
git clone --depth 20 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
|
git fetch origin ${GITHUB_BASE_REF}:base
|
|
- name: Require [guardrail-change] in commits touching guardrails
|
|
run: |
|
|
changed=$(git diff --name-only base...HEAD)
|
|
echo "$changed" | grep -qE '^(\.claude/settings\.json|\.claude/rules/loc-exceptions\.txt|scripts/check-loc\.sh|scripts/githooks/pre-commit|AGENTS\.(python|go|typescript)\.md)$' || exit 0
|
|
if ! git log base..HEAD --format=%B | grep -q '\[guardrail-change\]'; then
|
|
echo "::error::Guardrail files modified without [guardrail-change] in any commit message."
|
|
exit 1
|
|
fi
|
|
|
|
# ── LOC budget (only if files changed) ───────────────────────────────────
|
|
loc-budget:
|
|
runs-on: docker
|
|
container: alpine:3.20
|
|
needs: detect-changes
|
|
if: needs.detect-changes.outputs.any == 'true'
|
|
steps:
|
|
- name: Checkout
|
|
run: |
|
|
apk add --no-cache git bash
|
|
git clone --depth 50 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
|
- name: Enforce 500-line hard cap
|
|
run: |
|
|
chmod +x scripts/check-loc.sh
|
|
scripts/check-loc.sh
|
|
|
|
# ── Secret scanning (PR only) ────────────────────────────────────────────
|
|
secret-scan:
|
|
runs-on: docker
|
|
container: zricethezav/gitleaks:v8.21.2
|
|
if: github.event_name == 'pull_request'
|
|
steps:
|
|
- name: Checkout
|
|
run: |
|
|
apk add --no-cache git
|
|
git clone --depth 50 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
|
- name: Scan for secrets
|
|
run: |
|
|
gitleaks detect --source . --no-git \
|
|
--exit-code 1 \
|
|
--redact \
|
|
|| { echo "::error::Secrets detected — remove them before merging."; exit 1; }
|
|
|
|
# ── Go lint + build (PR only, gated on ai-compliance-sdk changes) ────────
|
|
go-lint:
|
|
runs-on: docker
|
|
needs: detect-changes
|
|
if: github.event_name == 'pull_request' && needs.detect-changes.outputs.sdk == 'true'
|
|
container: golangci/golangci-lint:v1.62-alpine
|
|
steps:
|
|
- name: Checkout
|
|
run: |
|
|
apk add --no-cache git
|
|
git clone --depth 1 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
|
- name: Lint ai-compliance-sdk
|
|
run: |
|
|
[ -d "ai-compliance-sdk" ] || exit 0
|
|
cd ai-compliance-sdk
|
|
golangci-lint run --timeout 5m ./...
|
|
- name: Build ai-compliance-sdk
|
|
run: |
|
|
[ -d "ai-compliance-sdk" ] || exit 0
|
|
cd ai-compliance-sdk
|
|
go build ./...
|
|
|
|
# ── Python lint + import check (PR only, gated on python service changes) ─
|
|
python-lint:
|
|
runs-on: docker
|
|
needs: detect-changes
|
|
if: github.event_name == 'pull_request' && needs.detect-changes.outputs.any_python == 'true'
|
|
container: python:3.12-slim
|
|
steps:
|
|
- name: Checkout
|
|
run: |
|
|
apt-get update -qq && apt-get install -y -qq git > /dev/null 2>&1
|
|
git clone --depth 1 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
|
- name: Lint (ruff) + type-check (mypy)
|
|
run: |
|
|
pip install --quiet ruff mypy
|
|
fail=0
|
|
for svc in backend-compliance document-crawler dsms-gateway compliance-tts-service; do
|
|
[ -d "$svc" ] || continue
|
|
echo "=== ruff: $svc ===" && ruff check "$svc/" --output-format=github || fail=1
|
|
done
|
|
if [ -f "backend-compliance/mypy.ini" ]; then
|
|
cd backend-compliance && mypy compliance/ || fail=1
|
|
fi
|
|
exit $fail
|
|
- name: Import sanity check (catches NameError at collection time)
|
|
run: |
|
|
cd backend-compliance
|
|
pip install --quiet --no-cache-dir -r requirements.txt 2>/dev/null || true
|
|
export PYTHONPATH="$(pwd):${PYTHONPATH:-}"
|
|
python -c "import compliance; print('Import OK')" \
|
|
|| { echo "::error::compliance package fails to import — missing import or syntax error."; exit 1; }
|
|
|
|
# ── Node.js lint + type-check (PR only, gated on Next.js service changes) ─
|
|
nodejs-lint:
|
|
runs-on: docker
|
|
needs: detect-changes
|
|
if: github.event_name == 'pull_request' && needs.detect-changes.outputs.any_node == 'true'
|
|
container: node:20-alpine
|
|
steps:
|
|
- name: Checkout
|
|
run: |
|
|
apk add --no-cache git
|
|
git clone --depth 1 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
|
- name: Lint + type-check
|
|
run: |
|
|
fail=0
|
|
for svc in admin-compliance developer-portal; do
|
|
[ -d "$svc" ] || continue
|
|
echo "=== $svc: install ===" && (cd "$svc" && npm ci --silent 2>/dev/null || npm install --silent)
|
|
echo "=== $svc: next lint ===" && (cd "$svc" && npx next lint) || fail=1
|
|
echo "=== $svc: tsc ===" && (cd "$svc" && npx tsc --noEmit) || fail=1
|
|
done
|
|
exit $fail
|
|
|
|
# ── Node.js build — next build (gated on Next.js service changes) ───────
|
|
nodejs-build:
|
|
runs-on: docker
|
|
container: node:20-alpine
|
|
needs: detect-changes
|
|
if: needs.detect-changes.outputs.any_node == 'true'
|
|
steps:
|
|
- name: Checkout
|
|
run: |
|
|
apk add --no-cache git
|
|
git clone --depth 1 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
|
- name: Build Next.js services
|
|
run: |
|
|
fail=0
|
|
for svc in admin-compliance developer-portal; do
|
|
[ -d "$svc" ] || continue
|
|
echo "=== $svc: install ==="
|
|
(cd "$svc" && npm ci --silent 2>/dev/null || npm install --silent)
|
|
echo "=== $svc: next build ==="
|
|
(cd "$svc" && \
|
|
NEXT_PUBLIC_API_URL=https://api-dev.breakpilot.ai \
|
|
NEXT_PUBLIC_SDK_URL=https://sdk-dev.breakpilot.ai \
|
|
npm run build) || fail=1
|
|
done
|
|
exit $fail
|
|
|
|
# ── Dependency audit (PR only) ───────────────────────────────────────────
|
|
dep-audit:
|
|
runs-on: docker
|
|
if: github.event_name == 'pull_request'
|
|
container: python:3.12-slim
|
|
steps:
|
|
- name: Checkout
|
|
run: |
|
|
apt-get update -qq && apt-get install -y -qq git curl > /dev/null 2>&1
|
|
git clone --depth 1 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
|
- name: Install Node.js + Go
|
|
run: |
|
|
curl -fsSL https://deb.nodesource.com/setup_20.x | bash - > /dev/null 2>&1
|
|
apt-get install -y nodejs golang-go > /dev/null 2>&1
|
|
- name: Python — pip-audit
|
|
run: |
|
|
pip install --quiet pip-audit
|
|
fail=0
|
|
for svc in backend-compliance document-crawler dsms-gateway compliance-tts-service; do
|
|
[ -f "$svc/requirements.txt" ] || continue
|
|
echo "=== pip-audit: $svc ==="
|
|
pip-audit -r "$svc/requirements.txt" --skip-editable -f columns || fail=1
|
|
done
|
|
exit $fail
|
|
- name: Node.js — npm audit
|
|
run: |
|
|
fail=0
|
|
for svc in admin-compliance developer-portal; do
|
|
[ -d "$svc" ] || continue
|
|
echo "=== npm audit: $svc ==="
|
|
(cd "$svc" && npm audit --audit-level=moderate --json 2>/dev/null | \
|
|
node -e "const d=JSON.parse(require('fs').readFileSync('/dev/stdin','utf8')); \
|
|
const hi=Object.values(d.vulnerabilities||{}).filter(v=>['high','critical'].includes(v.severity)).length; \
|
|
if(hi>0){console.error('HIGH/CRITICAL: '+hi);process.exit(1)}") || fail=1
|
|
done
|
|
exit $fail
|
|
- name: Go — govulncheck
|
|
run: |
|
|
[ -d "ai-compliance-sdk" ] || exit 0
|
|
go install golang.org/x/vuln/cmd/govulncheck@latest 2>/dev/null
|
|
cd ai-compliance-sdk && govulncheck ./... || true
|
|
# Non-blocking until Go module versions are pinned
|
|
|
|
# ── SBOM + vulnerability scan (PR only) ─────────────────────────────────
|
|
sbom-scan:
|
|
runs-on: docker
|
|
if: github.event_name == 'pull_request'
|
|
container: alpine:3.20
|
|
steps:
|
|
- name: Checkout
|
|
run: |
|
|
apk add --no-cache git curl bash
|
|
git clone --depth 1 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
|
- name: Install syft + grype
|
|
run: |
|
|
curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b /usr/local/bin
|
|
curl -sSfL https://raw.githubusercontent.com/anchore/grype/main/install.sh | sh -s -- -b /usr/local/bin
|
|
- name: Generate SBOM
|
|
run: mkdir -p sbom-out && syft dir:. -o cyclonedx-json=sbom-out/sbom.cdx.json -q
|
|
- name: Vulnerability scan (fail on high+)
|
|
run: grype sbom:sbom-out/sbom.cdx.json --fail-on high -q
|
|
|
|
# ── Tests (gated per service) ────────────────────────────────────────────
|
|
test-go:
|
|
runs-on: docker
|
|
container: golang:1.24-alpine
|
|
needs: detect-changes
|
|
if: needs.detect-changes.outputs.sdk == 'true'
|
|
env:
|
|
CGO_ENABLED: "0"
|
|
steps:
|
|
- name: Checkout
|
|
run: |
|
|
apk add --no-cache git
|
|
git clone --depth 1 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
|
- name: Test ai-compliance-sdk
|
|
run: |
|
|
[ -d "ai-compliance-sdk" ] || exit 0
|
|
cd ai-compliance-sdk
|
|
go test -v -coverprofile=coverage.out ./...
|
|
go tool cover -func=coverage.out | tail -1
|
|
|
|
test-python-backend:
|
|
runs-on: docker
|
|
container: python:3.12-slim
|
|
needs: detect-changes
|
|
if: needs.detect-changes.outputs.backend == 'true'
|
|
env:
|
|
CI: "true"
|
|
steps:
|
|
- name: Checkout
|
|
run: |
|
|
apt-get update -qq && apt-get install -y -qq git > /dev/null 2>&1
|
|
git clone --depth 1 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
|
- name: Test backend-compliance
|
|
run: |
|
|
[ -d "backend-compliance" ] || exit 0
|
|
cd backend-compliance
|
|
export PYTHONPATH="$(pwd):${PYTHONPATH:-}"
|
|
pip install --quiet --no-cache-dir -r requirements.txt 2>/dev/null || true
|
|
pip install --quiet --no-cache-dir pytest pytest-asyncio
|
|
python -m pytest compliance/tests/ -v --tb=short
|
|
|
|
test-python-document-crawler:
|
|
runs-on: docker
|
|
container: python:3.12-slim
|
|
needs: detect-changes
|
|
if: needs.detect-changes.outputs.crawler == 'true'
|
|
env:
|
|
CI: "true"
|
|
steps:
|
|
- name: Checkout
|
|
run: |
|
|
apt-get update -qq && apt-get install -y -qq git > /dev/null 2>&1
|
|
git clone --depth 1 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
|
- name: Test document-crawler
|
|
run: |
|
|
[ -d "document-crawler" ] || exit 0
|
|
cd document-crawler
|
|
export PYTHONPATH="$(pwd):${PYTHONPATH:-}"
|
|
pip install --quiet --no-cache-dir -r requirements.txt 2>/dev/null || true
|
|
pip install --quiet --no-cache-dir pytest pytest-asyncio
|
|
python -m pytest tests/ -v --tb=short
|
|
|
|
test-python-dsms-gateway:
|
|
runs-on: docker
|
|
container: python:3.12-slim
|
|
needs: detect-changes
|
|
if: needs.detect-changes.outputs.dsms_gateway == 'true'
|
|
env:
|
|
CI: "true"
|
|
steps:
|
|
- name: Checkout
|
|
run: |
|
|
apt-get update -qq && apt-get install -y -qq git > /dev/null 2>&1
|
|
git clone --depth 1 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
|
- name: Test dsms-gateway
|
|
run: |
|
|
[ -d "dsms-gateway" ] || exit 0
|
|
cd dsms-gateway
|
|
export PYTHONPATH="$(pwd):${PYTHONPATH:-}"
|
|
pip install --quiet --no-cache-dir -r requirements.txt 2>/dev/null || true
|
|
pip install --quiet --no-cache-dir pytest pytest-asyncio
|
|
python -m pytest test_main.py -v --tb=short
|
|
|
|
# ── OpenAPI contract validation (always) ─────────────────────────────────
|
|
validate-canonical-controls:
|
|
runs-on: docker
|
|
container: python:3.12-slim
|
|
steps:
|
|
- name: Checkout
|
|
run: |
|
|
apt-get update -qq && apt-get install -y -qq git > /dev/null 2>&1
|
|
git clone --depth 1 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
|
- name: Validate controls
|
|
run: python scripts/validate-controls.py
|