name: Release Gate on: pull_request: branches: [main] types: [opened, synchronize, reopened] concurrency: group: release-gate-${{ github.ref }} cancel-in-progress: true env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true REGISTRY: ghcr.io IMAGE_NAME: ${{ github.repository }} jobs: # ────────────────────────────────────────────────────────────────────────── # Phase 1 — Parallel validation (no Docker needed) # ────────────────────────────────────────────────────────────────────────── lint-typecheck: name: Lint & type-check runs-on: ubuntu-latest steps: - uses: actions/checkout@v4.3.1 - uses: actions/setup-python@v5 with: python-version: "3.12" cache: pip cache-dependency-path: document-parser/requirements.txt - uses: actions/setup-node@v4 with: node-version: 22 cache: npm cache-dependency-path: frontend/package-lock.json - name: Install backend deps run: | pip install --upgrade pip pip install -r document-parser/requirements.txt pip install ruff - name: Install frontend deps run: cd frontend && npm ci - name: Backend lint run: cd document-parser && ruff check . - name: Frontend lint run: cd frontend && npx eslint src/ - name: Frontend type-check run: cd frontend && npm run type-check unit-tests: name: Unit tests runs-on: ubuntu-latest steps: - uses: actions/checkout@v4.3.1 - uses: actions/setup-python@v5 with: python-version: "3.12" cache: pip cache-dependency-path: document-parser/requirements.txt - uses: actions/setup-node@v4 with: node-version: 22 cache: npm cache-dependency-path: frontend/package-lock.json - name: Install system deps run: sudo apt-get update && sudo apt-get install -y --no-install-recommends poppler-utils - name: Backend tests run: | cd document-parser pip install --upgrade pip pip install -r requirements.txt pip install pytest pytest-asyncio httpx ruff pytest tests/ -v --tb=short 2>&1 | tee /tmp/backend-tests.txt - name: Frontend tests run: | cd frontend npm ci npm run test:run 2>&1 | tee /tmp/frontend-tests.txt - name: Upload test results if: always() uses: actions/upload-artifact@v4.6.2 with: name: unit-test-results path: /tmp/*-tests.txt dep-audit: name: Dependency audit runs-on: ubuntu-latest steps: - uses: actions/checkout@v4.3.1 - uses: actions/setup-python@v5 with: python-version: "3.12" cache: pip cache-dependency-path: document-parser/requirements.txt - uses: actions/setup-node@v4 with: node-version: 22 cache: npm cache-dependency-path: frontend/package-lock.json - name: Install deps run: | cd document-parser && pip install --upgrade pip && pip install -r requirements.txt && pip install pip-audit cd ../frontend && npm ci - name: Python audit id: pip_audit continue-on-error: true run: | cd document-parser pip-audit --format=json --output=/tmp/pip-audit.json 2>&1 || true pip-audit 2>&1 | tee /tmp/pip-audit.txt # Check for critical vulnerabilities if pip-audit --format=json 2>/dev/null | python3 -c " import sys, json data = json.load(sys.stdin) vulns = data.get('dependencies', []) crits = [v for dep in vulns for v in dep.get('vulns', []) if 'CRITICAL' in v.get('fix_versions', [''])] sys.exit(1 if crits else 0) " 2>/dev/null; then echo "critical=false" >> "$GITHUB_OUTPUT" else echo "critical=true" >> "$GITHUB_OUTPUT" fi - name: Node audit id: npm_audit continue-on-error: true run: | cd frontend npm audit --json > /tmp/npm-audit.json 2>&1 || true npm audit 2>&1 | tee /tmp/npm-audit.txt # Check for critical vulnerabilities CRITICAL_COUNT=$(npm audit --json 2>/dev/null | python3 -c " import sys, json try: data = json.load(sys.stdin) print(data.get('metadata', {}).get('vulnerabilities', {}).get('critical', 0)) except: print(0) " 2>/dev/null || echo "0") if [ "$CRITICAL_COUNT" -gt 0 ]; then echo "critical=true" >> "$GITHUB_OUTPUT" else echo "critical=false" >> "$GITHUB_OUTPUT" fi - name: Upload audit results if: always() uses: actions/upload-artifact@v4.6.2 with: name: dep-audit-results path: /tmp/*-audit.* - name: Fail on critical vulnerabilities if: steps.pip_audit.outputs.critical == 'true' || steps.npm_audit.outputs.critical == 'true' run: | echo "::error::Critical vulnerabilities found in dependencies" exit 1 audit-checks: name: Audit checks (commands.sh) runs-on: ubuntu-latest steps: - uses: actions/checkout@v4.3.1 - name: Run automated audit checks id: audit run: | bash profiles/fastapi-vue/commands.sh 2>&1 | tee /tmp/audit-checks.txt continue-on-error: true - name: Parse audit results id: results if: always() run: | # Strip ANSI codes before counting CLEAN=$(sed 's/\x1b\[[0-9;]*m//g' /tmp/audit-checks.txt) PASS=$(echo "$CLEAN" | grep -c "PASS" || true) WARN=$(echo "$CLEAN" | grep -c "WARN" || true) FAIL=$(echo "$CLEAN" | grep -c "FAIL" || true) echo "pass=${PASS:-0}" >> "$GITHUB_OUTPUT" echo "warn=${WARN:-0}" >> "$GITHUB_OUTPUT" echo "fail=${FAIL:-0}" >> "$GITHUB_OUTPUT" - name: Upload audit results if: always() uses: actions/upload-artifact@v4.6.2 with: name: audit-checks-results path: /tmp/audit-checks.txt - name: Fail on audit failures if: steps.audit.outcome == 'failure' run: | echo "::error::Audit checks failed — review profiles/fastapi-vue/commands.sh output" exit 1 # ────────────────────────────────────────────────────────────────────────── # Phase 2 — Docker build & validation # ────────────────────────────────────────────────────────────────────────── docker-build: name: Docker build — ${{ matrix.target }} runs-on: ubuntu-latest strategy: matrix: target: [remote, local] steps: - uses: actions/checkout@v4.3.1 - uses: docker/setup-buildx-action@v3 - name: Extract version from branch id: version run: | # release/1.2.3 → 1.2.3 BRANCH="${GITHUB_HEAD_REF:-$GITHUB_REF_NAME}" VERSION=$(echo "$BRANCH" | sed 's|release/||') echo "value=$VERSION" >> "$GITHUB_OUTPUT" - name: Build image uses: docker/build-push-action@v6 with: context: . target: ${{ matrix.target }} load: true tags: docling-studio:${{ matrix.target }} build-args: APP_VERSION=${{ steps.version.outputs.value }} cache-from: type=gha,scope=${{ matrix.target }} cache-to: type=gha,mode=max,scope=${{ matrix.target }} - name: Save image run: | docker save docling-studio:${{ matrix.target }} | gzip > /tmp/docling-studio-${{ matrix.target }}.tar.gz - name: Upload image artifact uses: actions/upload-artifact@v4.6.2 with: name: docker-image-${{ matrix.target }} path: /tmp/docling-studio-${{ matrix.target }}.tar.gz retention-days: 1 - name: Record image size run: | SIZE_BYTES=$(docker image inspect docling-studio:${{ matrix.target }} --format='{{.Size}}') SIZE_MB=$((SIZE_BYTES / 1024 / 1024)) echo "$SIZE_MB" > /tmp/image-size-${{ matrix.target }}.txt echo "Image size (${{ matrix.target }}): ${SIZE_MB} MB" - name: Upload size artifact uses: actions/upload-artifact@v4.6.2 with: name: image-size-${{ matrix.target }} path: /tmp/image-size-${{ matrix.target }}.txt docker-smoke: name: Docker smoke test needs: [docker-build] runs-on: ubuntu-latest steps: - uses: actions/checkout@v4.3.1 - name: Download remote image uses: actions/download-artifact@v4.3.0 with: name: docker-image-remote path: /tmp - name: Load remote image run: docker load < /tmp/docling-studio-remote.tar.gz - name: Smoke test — remote run: | docker run -d --name smoke-remote -p 3000:3000 \ -e RATE_LIMIT_RPM=0 \ docling-studio:remote echo "Waiting for container to start..." for i in $(seq 1 30); do if curl -sf http://localhost:3000/api/health > /dev/null 2>&1; then echo "Remote image healthy!" curl -s http://localhost:3000/api/health | python3 -m json.tool break fi if [ "$i" -eq 30 ]; then echo "::error::Remote image failed health check" docker logs smoke-remote exit 1 fi sleep 2 done # Verify response fields HEALTH=$(curl -s http://localhost:3000/api/health) ENGINE=$(echo "$HEALTH" | python3 -c "import sys,json; print(json.load(sys.stdin)['engine'])") STATUS=$(echo "$HEALTH" | python3 -c "import sys,json; print(json.load(sys.stdin)['status'])") if [ "$STATUS" != "ok" ]; then echo "::error::Health status is '$STATUS', expected 'ok'" exit 1 fi if [ "$ENGINE" != "remote" ]; then echo "::error::Engine is '$ENGINE', expected 'remote'" exit 1 fi echo "Remote smoke test passed" docker stop smoke-remote && docker rm smoke-remote image-scan: name: Security scan — ${{ matrix.target }} needs: [docker-build] runs-on: ubuntu-latest strategy: matrix: target: [remote, local] steps: - uses: actions/checkout@v4.3.1 - name: Download image uses: actions/download-artifact@v4.3.0 with: name: docker-image-${{ matrix.target }} path: /tmp - name: Load image run: docker load < /tmp/docling-studio-${{ matrix.target }}.tar.gz - name: Run Trivy — CRITICAL (blocking) uses: aquasecurity/trivy-action@v0.35.0 with: image-ref: docling-studio:${{ matrix.target }} format: table exit-code: 1 severity: CRITICAL output: /tmp/trivy-critical-${{ matrix.target }}.txt - name: Run Trivy — HIGH (informational) if: always() uses: aquasecurity/trivy-action@v0.35.0 with: image-ref: docling-studio:${{ matrix.target }} format: table exit-code: 0 severity: HIGH output: /tmp/trivy-high-${{ matrix.target }}.txt - name: Annotate HIGH vulnerabilities if: always() run: | HIGH_COUNT=$(grep -c "HIGH" /tmp/trivy-high-${{ matrix.target }}.txt 2>/dev/null || echo "0") if [ "$HIGH_COUNT" -gt 0 ]; then echo "::warning::${{ matrix.target }} image has $HIGH_COUNT HIGH severity vulnerabilities — review Trivy report" else echo "No HIGH vulnerabilities found in ${{ matrix.target }} image" fi - name: Upload Trivy reports if: always() uses: actions/upload-artifact@v4.6.2 with: name: trivy-${{ matrix.target }} path: /tmp/trivy-*-${{ matrix.target }}.txt image-size: name: Image size check needs: [docker-build] runs-on: ubuntu-latest permissions: packages: read steps: - uses: actions/checkout@v4.3.1 - name: Download size artifacts uses: actions/download-artifact@v4.3.0 with: pattern: image-size-* merge-multiple: true path: /tmp/sizes - name: Get previous release sizes id: prev env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | # Find the latest release tag PREV_TAG=$(gh release list --repo "${{ github.repository }}" --limit 1 --json tagName -q '.[0].tagName' 2>/dev/null || echo "") if [ -z "$PREV_TAG" ]; then echo "No previous release found — skipping delta check" echo "remote=0" >> "$GITHUB_OUTPUT" echo "local=0" >> "$GITHUB_OUTPUT" echo "found=false" >> "$GITHUB_OUTPUT" exit 0 fi echo "Previous release: $PREV_TAG" echo "found=true" >> "$GITHUB_OUTPUT" # Pull previous images and get sizes for target in remote local; do IMAGE="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${PREV_TAG#v}-${target}" if docker pull "$IMAGE" 2>/dev/null; then PREV_SIZE=$(docker image inspect "$IMAGE" --format='{{.Size}}') PREV_MB=$((PREV_SIZE / 1024 / 1024)) echo "${target}=${PREV_MB}" >> "$GITHUB_OUTPUT" echo "Previous $target size: ${PREV_MB} MB" else echo "Could not pull $IMAGE — skipping $target delta" echo "${target}=0" >> "$GITHUB_OUTPUT" fi done - name: Compare sizes id: delta run: | for target in remote local; do CURRENT=$(cat /tmp/sizes/image-size-${target}.txt 2>/dev/null || echo "0") if [ "$target" = "remote" ]; then PREV="${{ steps.prev.outputs.remote }}" else PREV="${{ steps.prev.outputs.local }}" fi if [ "$PREV" -gt 0 ] 2>/dev/null; then DELTA=$((CURRENT - PREV)) DELTA_PCT=$((DELTA * 100 / PREV)) echo "${target}_current=${CURRENT}" >> "$GITHUB_OUTPUT" echo "${target}_prev=${PREV}" >> "$GITHUB_OUTPUT" echo "${target}_delta=${DELTA}" >> "$GITHUB_OUTPUT" echo "${target}_delta_pct=${DELTA_PCT}" >> "$GITHUB_OUTPUT" echo "$target: ${CURRENT}MB (was ${PREV}MB, delta: ${DELTA_PCT}%)" if [ "$DELTA_PCT" -gt 10 ]; then echo "::warning::$target image grew by ${DELTA_PCT}% (${PREV}MB → ${CURRENT}MB)" elif [ "$DELTA_PCT" -lt -10 ]; then echo "::notice::$target image shrank by ${DELTA_PCT#-}% (${PREV}MB → ${CURRENT}MB)" fi else echo "${target}_current=${CURRENT}" >> "$GITHUB_OUTPUT" echo "${target}_prev=n/a" >> "$GITHUB_OUTPUT" echo "${target}_delta=n/a" >> "$GITHUB_OUTPUT" echo "${target}_delta_pct=n/a" >> "$GITHUB_OUTPUT" echo "$target: ${CURRENT}MB (no previous baseline)" fi done - name: Save size report run: | cat > /tmp/image-size-report.txt < /dev/null 2>&1; then echo "Backend healthy" exit 0 fi echo "Waiting for backend... ($i/30)" sleep 5 done echo "Backend failed to start" docker compose logs exit 1 - name: Run E2E API tests (full scope) run: | mvn test -f e2e/api/pom.xml \ -DbaseUrl=http://localhost:3000 \ -Dkarate.options="--tags @smoke,@regression,@e2e" - name: Upload Karate reports if: always() uses: actions/upload-artifact@v4.6.2 with: name: karate-api-reports path: e2e/api/target/karate-reports/ - name: Tear down if: always() run: docker compose down e2e-ui: name: E2E UI tests (@critical) needs: [docker-smoke] runs-on: ubuntu-latest steps: - uses: actions/checkout@v4.3.1 - uses: actions/setup-python@v5 with: python-version: "3.12" - uses: actions/setup-java@v4 with: java-version: "17" distribution: temurin cache: maven - name: Install Chrome uses: browser-actions/setup-chrome@v1 with: chrome-version: stable - name: Generate test PDFs run: | pip install fpdf2 pypdfium2 python e2e/generate-test-data.py - name: Start stack run: docker compose up -d --wait --build timeout-minutes: 10 env: RATE_LIMIT_RPM: "0" - name: Wait for health run: | for i in $(seq 1 30); do if curl -sf http://localhost:3000/api/health > /dev/null 2>&1; then echo "Backend healthy" exit 0 fi echo "Waiting for backend... ($i/30)" sleep 5 done echo "Backend failed to start" docker compose logs exit 1 - name: Run critical UI tests run: > mvn test -f e2e/ui/pom.xml -DbaseUrl=http://localhost:3000 -DuiBaseUrl=http://localhost:3000 -Dkarate.options="--tags @critical" - name: Upload Karate UI reports if: always() uses: actions/upload-artifact@v4.6.2 with: name: karate-ui-reports path: e2e/ui/target/karate-reports/ - name: Tear down if: always() run: docker compose down # ────────────────────────────────────────────────────────────────────────── # Phase 4 — Release summary (comment on PR) # ────────────────────────────────────────────────────────────────────────── release-summary: name: Release summary if: always() && github.event_name == 'pull_request' needs: - lint-typecheck - unit-tests - dep-audit - audit-checks - docker-build - docker-smoke - image-scan - image-size - e2e-api - e2e-ui runs-on: ubuntu-latest permissions: pull-requests: write actions: read steps: - uses: actions/checkout@v4.3.1 - name: Download image-size artifacts uses: actions/download-artifact@v4.3.0 with: pattern: image-size-* path: /tmp/artifacts - name: Download audit-checks artifact uses: actions/download-artifact@v4.3.0 with: name: audit-checks-results path: /tmp/artifacts/audit-checks-results - name: Build summary comment id: summary env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | # Job results from needs context LINT="${{ needs.lint-typecheck.result }}" TESTS="${{ needs.unit-tests.result }}" DEP="${{ needs.dep-audit.result }}" AUDIT="${{ needs.audit-checks.result }}" BUILD="${{ needs.docker-build.result }}" SMOKE="${{ needs.docker-smoke.result }}" SCAN="${{ needs.image-scan.result }}" SIZE="${{ needs.image-size.result }}" E2E_API="${{ needs.e2e-api.result }}" E2E_UI="${{ needs.e2e-ui.result }}" # Determine verdict BLOCKING_FAILED="false" for result in "$LINT" "$TESTS" "$BUILD" "$SMOKE" "$SCAN" "$E2E_API" "$E2E_UI"; do if [ "$result" = "failure" ]; then BLOCKING_FAILED="true" fi done if [ "$BLOCKING_FAILED" = "true" ]; then VERDICT="NO-GO" VERDICT_ICON="🔴" elif [ "$DEP" = "failure" ] || [ "$AUDIT" = "failure" ]; then VERDICT="GO CONDITIONAL" VERDICT_ICON="🟡" else VERDICT="GO" VERDICT_ICON="🟢" fi # Status emoji helper status_icon() { case "$1" in success) echo "✅" ;; failure) echo "❌" ;; skipped) echo "⏭️" ;; cancelled) echo "🚫" ;; *) echo "❓" ;; esac } # Read image sizes REMOTE_SIZE=$(cat /tmp/artifacts/image-size-remote/image-size-remote.txt 2>/dev/null || echo "?") LOCAL_SIZE=$(cat /tmp/artifacts/image-size-local/image-size-local.txt 2>/dev/null || echo "?") # Read size report SIZE_REPORT=$(cat /tmp/artifacts/image-size-report/image-size-report.txt 2>/dev/null || echo "No size data available") # Read audit summary AUDIT_PASS=$(grep -c "PASS" /tmp/artifacts/audit-checks-results/audit-checks.txt 2>/dev/null || echo "?") AUDIT_WARN=$(grep -c "WARN" /tmp/artifacts/audit-checks-results/audit-checks.txt 2>/dev/null || echo "?") AUDIT_FAIL=$(grep -c "FAIL" /tmp/artifacts/audit-checks-results/audit-checks.txt 2>/dev/null || echo "?") # Build the comment cat > /tmp/summary.md << ENDOFCOMMENT ## ${VERDICT_ICON} Release Gate — ${VERDICT} ### Validation Results | Check | Status | Blocking | |-------|--------|----------| | Lint & type-check | $(status_icon "$LINT") | Yes | | Unit tests | $(status_icon "$TESTS") | Yes | | Dependency audit | $(status_icon "$DEP") | Yes (CRITICAL) | | Audit checks | $(status_icon "$AUDIT") | Yes | | Docker build | $(status_icon "$BUILD") | Yes | | Docker smoke test | $(status_icon "$SMOKE") | Yes | | Image security scan | $(status_icon "$SCAN") | Yes (CRITICAL) | | Image size check | $(status_icon "$SIZE") | No | | E2E API (full scope) | $(status_icon "$E2E_API") | Yes | | E2E UI (@critical) | $(status_icon "$E2E_UI") | Yes | ### Audit Checks Summary | PASS | WARN | FAIL | |------|------|------| | ${AUDIT_PASS} | ${AUDIT_WARN} | ${AUDIT_FAIL} | ### Docker Image Sizes | Target | Size | |--------|------| | remote | ${REMOTE_SIZE} MB | | local | ${LOCAL_SIZE} MB |
Size delta vs previous release \`\`\` ${SIZE_REPORT} \`\`\`
--- Generated by release-gate.yml — see workflow run for full details ENDOFCOMMENT - name: Post or update PR comment env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | PR_NUMBER="${{ github.event.pull_request.number }}" COMMENT_TAG="" # Check for existing comment EXISTING_COMMENT_ID=$(gh api \ "repos/${{ github.repository }}/issues/${PR_NUMBER}/comments" \ --jq ".[] | select(.body | contains(\"$COMMENT_TAG\")) | .id" \ 2>/dev/null | head -1) BODY=$(cat /tmp/summary.md) BODY="${COMMENT_TAG} ${BODY}" if [ -n "$EXISTING_COMMENT_ID" ]; then gh api \ "repos/${{ github.repository }}/issues/comments/${EXISTING_COMMENT_ID}" \ -X PATCH \ -f body="$BODY" echo "Updated existing comment #${EXISTING_COMMENT_ID}" else gh api \ "repos/${{ github.repository }}/issues/${PR_NUMBER}/comments" \ -f body="$BODY" echo "Posted new comment" fi