Skip to content

Release/0.6.1

Release/0.6.1 #62

Workflow file for this run

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
trivyignores: .trivyignore.yaml
# `latest` instead of the action default — the previously hardcoded
# v0.69.3 was yanked from GitHub releases mid-run (2026-04-29) and
# broke the gate. Following Trivy stable is safer than chasing a
# specific tag that can vanish.
version: latest
- 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
trivyignores: .trivyignore.yaml
version: latest
- 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 <<EOF
Image Size Report
=================
remote: ${{ steps.delta.outputs.remote_current }}MB (prev: ${{ steps.delta.outputs.remote_prev }}MB, delta: ${{ steps.delta.outputs.remote_delta_pct }}%)
local: ${{ steps.delta.outputs.local_current }}MB (prev: ${{ steps.delta.outputs.local_prev }}MB, delta: ${{ steps.delta.outputs.local_delta_pct }}%)
EOF
cat /tmp/image-size-report.txt
- name: Upload size report
uses: actions/upload-artifact@v4.6.2
with:
name: image-size-report
path: /tmp/image-size-report.txt
# ──────────────────────────────────────────────────────────────────────────
# Phase 3 — E2E tests on built images
# ──────────────────────────────────────────────────────────────────────────
e2e-api:
name: E2E API tests (full scope)
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: 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 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 |
<details>
<summary>Size delta vs previous release</summary>
\`\`\`
${SIZE_REPORT}
\`\`\`
</details>
---
<sub>Generated by <b>release-gate.yml</b> — see <a href="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}">workflow run</a> for full details</sub>
ENDOFCOMMENT
- name: Post or update PR comment
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
PR_NUMBER="${{ github.event.pull_request.number }}"
COMMENT_TAG="<!-- release-gate-summary -->"
# 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