Files
breakpilot-compliance/.gitea/workflows/ci.yaml
T
Sharang Parnerkar 256deb70c7 ci: gate jobs on change detection + tag-based deploy ordering [guardrail-change]
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>
2026-05-13 16:39:43 +02:00

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