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>
This commit is contained in:
Sharang Parnerkar
2026-05-13 16:39:43 +02:00
parent c3f8e19e92
commit 256deb70c7
5 changed files with 296 additions and 22 deletions
+67 -9
View File
@@ -19,6 +19,49 @@ on:
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
@@ -55,10 +98,12 @@ jobs:
exit 1
fi
# ── LOC budget (always) ──────────────────────────────────────────────────
# ── 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: |
@@ -86,10 +131,11 @@ jobs:
--redact \
|| { echo "::error::Secrets detected — remove them before merging."; exit 1; }
# ── Go lint + build (PR only) ────────────────────────────────────────────
# ── Go lint + build (PR only, gated on ai-compliance-sdk changes) ────────
go-lint:
runs-on: docker
if: github.event_name == 'pull_request'
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
@@ -107,10 +153,11 @@ jobs:
cd ai-compliance-sdk
go build ./...
# ── Python lint + import check (PR only) ───────────────────────────────
# ── Python lint + import check (PR only, gated on python service changes)
python-lint:
runs-on: docker
if: github.event_name == 'pull_request'
needs: detect-changes
if: github.event_name == 'pull_request' && needs.detect-changes.outputs.any_python == 'true'
container: python:3.12-slim
steps:
- name: Checkout
@@ -137,10 +184,11 @@ jobs:
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) ────────────────────────────────
# ── Node.js lint + type-check (PR only, gated on Next.js service changes)
nodejs-lint:
runs-on: docker
if: github.event_name == 'pull_request'
needs: detect-changes
if: github.event_name == 'pull_request' && needs.detect-changes.outputs.any_node == 'true'
container: node:20-alpine
steps:
- name: Checkout
@@ -158,10 +206,12 @@ jobs:
done
exit $fail
# ── Node.js build — next build (PR + push to main) ───────────────────────
# ── 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: |
@@ -244,10 +294,12 @@ jobs:
- name: Vulnerability scan (fail on high+)
run: grype sbom:sbom-out/sbom.cdx.json --fail-on high -q
# ── Tests (PR + push to main) ────────────────────────────────────────────
# ── 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:
@@ -265,6 +317,8 @@ jobs:
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:
@@ -284,6 +338,8 @@ jobs:
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:
@@ -303,6 +359,8 @@ jobs:
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: