build(deps): bump the minor-and-patch group in /frontend with 5 updates #898
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: CI | |
| on: | |
| push: | |
| branches: [main] | |
| tags: ['v*.*.*'] | |
| pull_request: | |
| branches: [main] | |
| workflow_dispatch: | |
| permissions: | |
| contents: read | |
| pull-requests: write | |
| packages: write | |
| security-events: write | |
| id-token: write | |
| env: | |
| NODE_VERSION: '26' | |
| jobs: | |
| # CPF scaffold base checks (shellcheck, markdownlint, prettier, plugin-validation) | |
| base: | |
| uses: ./.github/workflows/ci-base.yml | |
| # Detect which services changed | |
| changes: | |
| runs-on: ubuntu-latest | |
| outputs: | |
| frontend: ${{ steps.filter.outputs.frontend }} | |
| api-service: ${{ steps.filter.outputs.api-service }} | |
| ingest: ${{ steps.filter.outputs.ingest }} | |
| memvid-service: ${{ steps.filter.outputs.memvid-service }} | |
| containers: ${{ steps.filter.outputs.containers }} | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - uses: dorny/paths-filter@v4 | |
| id: filter | |
| with: | |
| filters: | | |
| frontend: | |
| - 'frontend/**' | |
| - 'package.json' | |
| - 'package-lock.json' | |
| api-service: | |
| - 'api-service/**' | |
| - 'proto/**' | |
| ingest: | |
| - 'ingest/**' | |
| - 'data/**' | |
| memvid-service: | |
| - 'memvid-service/**' | |
| - 'proto/**' | |
| containers: | |
| - '**/Dockerfile' | |
| - 'frontend/**' | |
| - 'api-service/**' | |
| - 'memvid-service/**' | |
| - 'ingest/**' | |
| - 'proto/**' | |
| - 'deployment/**' | |
| - 'scripts/publish-ci.sh' | |
| # Frontend: TypeScript/React | |
| frontend: | |
| needs: changes | |
| if: ${{ needs.changes.outputs.frontend == 'true' }} | |
| runs-on: ubuntu-latest | |
| defaults: | |
| run: | |
| working-directory: frontend | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - name: Setup Node.js | |
| uses: actions/setup-node@v7 | |
| with: | |
| node-version: ${{ env.NODE_VERSION }} | |
| cache: 'npm' | |
| cache-dependency-path: frontend/package-lock.json | |
| - name: Install dependencies | |
| run: npm ci | |
| - name: Lint | |
| run: npm run lint | |
| - name: Type check | |
| # Validates under TypeScript 7 via the `typescript-7` alias (eslint/IDE | |
| # stay on TS6, the latest typescript-eslint supports). `tsc -b` traverses | |
| # tsconfig.json project references; a bare `tsc --noEmit` on the root | |
| # `files: []` solution config is a no-op. | |
| run: npm run typecheck | |
| - name: Test with coverage | |
| run: npm test -- --run --coverage 2>&1 | tee /tmp/test-output.txt | |
| - name: Write test summary | |
| if: always() | |
| run: | | |
| { | |
| echo "## Frontend Tests" | |
| echo "" | |
| if [ -f /tmp/test-output.txt ]; then | |
| TESTS=$(grep -oP 'Tests\s+\K\d+ passed' /tmp/test-output.txt || echo "unknown") | |
| DURATION=$(grep -oP 'Duration\s+\K[\d.]+s' /tmp/test-output.txt || echo "unknown") | |
| COVERAGE=$(grep -oP 'All files\s*\|\s*\K[\d.]+' /tmp/test-output.txt | head -1 || echo "unknown") | |
| echo "| Metric | Value |" | |
| echo "|--------|-------|" | |
| echo "| Tests | ${TESTS} |" | |
| echo "| Duration | ${DURATION} |" | |
| echo "| Line Coverage | ${COVERAGE}% |" | |
| echo "| Threshold | 10% |" | |
| fi | |
| } >> "$GITHUB_STEP_SUMMARY" || true | |
| shell: bash | |
| - name: Build | |
| run: npm run build | |
| # API Service: Python/FastAPI | |
| api-service: | |
| needs: changes | |
| if: ${{ needs.changes.outputs.api-service == 'true' }} | |
| runs-on: ubuntu-latest | |
| defaults: | |
| run: | |
| working-directory: api-service | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - name: Install uv | |
| uses: astral-sh/setup-uv@v7 | |
| with: | |
| enable-cache: true | |
| # Must run before `uv sync`: sync silently re-resolves a stale lock, so | |
| # pyproject.toml/uv.lock drift would otherwise pass CI unnoticed. | |
| - name: Verify uv.lock is in sync with pyproject.toml | |
| run: uv lock --check | |
| - name: Install dependencies | |
| run: uv sync --extra test --extra lint | |
| - name: Lint with ruff | |
| run: | | |
| uv run ruff check . | |
| uv run ruff format --check . | |
| - name: Type check with mypy | |
| run: uv run mypy . | |
| - name: Test with pytest | |
| run: uv run pytest -v --tb=short --cov --cov-report=term-missing 2>&1 | tee /tmp/test-output.txt | |
| - name: Write test summary | |
| if: always() | |
| run: | | |
| { | |
| echo "## API Service Tests" | |
| echo "" | |
| if [ -f /tmp/test-output.txt ]; then | |
| PASSED=$(grep -c "PASSED" /tmp/test-output.txt || true) | |
| FAILED=$(grep -c "FAILED" /tmp/test-output.txt || true) | |
| TOTAL=$((PASSED + FAILED)) | |
| COVERAGE=$(grep -oP 'TOTAL\s+\d+\s+\d+\s+\K\d+%' /tmp/test-output.txt || echo "unknown") | |
| echo "| Metric | Value |" | |
| echo "|--------|-------|" | |
| echo "| Passed | ${PASSED} |" | |
| echo "| Failed | ${FAILED} |" | |
| echo "| Total | ${TOTAL} |" | |
| echo "| Coverage | ${COVERAGE} |" | |
| echo "| Threshold | 85% |" | |
| fi | |
| } >> "$GITHUB_STEP_SUMMARY" || true | |
| shell: bash | |
| # Ingest: Python data pipeline | |
| ingest: | |
| needs: changes | |
| if: ${{ needs.changes.outputs.ingest == 'true' }} | |
| runs-on: ubuntu-latest | |
| defaults: | |
| run: | |
| working-directory: ingest | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - name: Install uv | |
| uses: astral-sh/setup-uv@v7 | |
| with: | |
| enable-cache: true | |
| # See the api-service job: this must precede `uv sync`. | |
| - name: Verify uv.lock is in sync with pyproject.toml | |
| run: uv lock --check | |
| - name: Install dependencies | |
| run: uv sync --extra test --extra lint | |
| - name: Lint with ruff | |
| run: | | |
| uv run ruff check . | |
| uv run ruff format --check . | |
| - name: Type check with mypy | |
| run: uv run mypy . | |
| - name: Cache HuggingFace models | |
| uses: actions/cache@v6 | |
| with: | |
| path: ~/.cache/huggingface/hub | |
| key: huggingface-models-v1 | |
| - name: Test with coverage | |
| env: | |
| HF_HUB_DOWNLOAD_TIMEOUT: '120' | |
| run: uv run pytest -v --tb=short -m "not slow" --cov=ingest --cov-report=term-missing --cov-fail-under=85 2>&1 | tee /tmp/test-output.txt | |
| - name: Write test summary | |
| if: always() | |
| run: | | |
| { | |
| echo "## Ingest Tests" | |
| echo "" | |
| if [ -f /tmp/test-output.txt ]; then | |
| PASSED=$(grep -c "PASSED" /tmp/test-output.txt || true) | |
| FAILED=$(grep -c "FAILED" /tmp/test-output.txt || true) | |
| COVERAGE=$(grep -oP 'TOTAL\s+\d+\s+\d+\s+\K\d+%' /tmp/test-output.txt || echo "unknown") | |
| echo "| Metric | Value |" | |
| echo "|--------|-------|" | |
| echo "| Passed | ${PASSED} |" | |
| echo "| Failed | ${FAILED} |" | |
| echo "| Coverage | ${COVERAGE} |" | |
| echo "| Threshold | 85% |" | |
| fi | |
| } >> "$GITHUB_STEP_SUMMARY" || true | |
| shell: bash | |
| # Memvid Service: Rust/gRPC (unit tests + library coverage, excludes real.rs) | |
| # real.rs coverage is handled by the memvid-integration job below | |
| memvid-service: | |
| needs: changes | |
| if: ${{ needs.changes.outputs.memvid-service == 'true' }} | |
| runs-on: ubuntu-latest | |
| defaults: | |
| run: | |
| working-directory: memvid-service | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - name: Setup Rust | |
| uses: dtolnay/rust-toolchain@master | |
| with: | |
| toolchain: '1.96.0' | |
| components: clippy, rustfmt | |
| - name: Cache cargo | |
| uses: Swatinem/rust-cache@v2 | |
| with: | |
| workspaces: memvid-service | |
| - name: Install protobuf compiler | |
| run: sudo apt-get update && sudo apt-get install -y protobuf-compiler | |
| - name: Check formatting | |
| run: cargo fmt --check | |
| - name: Clippy | |
| run: cargo clippy -- -D warnings | |
| - name: Test | |
| run: cargo test | |
| - name: Install cargo-tarpaulin | |
| run: cargo install cargo-tarpaulin | |
| - name: Coverage | |
| run: cargo tarpaulin --config tarpaulin-unit.toml 2>&1 | tee /tmp/test-output.txt | |
| - name: Write test summary | |
| if: always() | |
| run: | | |
| { | |
| echo "## Memvid Service Tests" | |
| echo "" | |
| if [ -f /tmp/test-output.txt ]; then | |
| COVERAGE=$(grep -oP '\d+\.\d+% coverage' /tmp/test-output.txt | tail -1 || echo "unknown") | |
| echo "| Metric | Value |" | |
| echo "|--------|-------|" | |
| echo "| Coverage | ${COVERAGE} |" | |
| echo "| Threshold | 85% |" | |
| fi | |
| } >> "$GITHUB_STEP_SUMMARY" || true | |
| shell: bash | |
| # Memvid Integration: real.rs coverage with actual .mv2 file | |
| # Runs ingest to produce a .mv2, then cargo tarpaulin on integration tests | |
| memvid-integration: | |
| needs: [changes, memvid-service] | |
| if: >- | |
| always() && | |
| needs.changes.result == 'success' && | |
| (needs.changes.outputs.memvid-service == 'true' || needs.changes.outputs.ingest == 'true') && | |
| needs.memvid-service.result != 'failure' | |
| runs-on: ubuntu-latest | |
| continue-on-error: true | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - name: Install uv | |
| uses: astral-sh/setup-uv@v7 | |
| with: | |
| enable-cache: true | |
| - name: Setup Rust | |
| uses: dtolnay/rust-toolchain@master | |
| with: | |
| toolchain: '1.96.0' | |
| - name: Cache cargo | |
| uses: Swatinem/rust-cache@v2 | |
| with: | |
| workspaces: memvid-service | |
| - name: Install protobuf compiler | |
| run: sudo apt-get update && sudo apt-get install -y protobuf-compiler | |
| - name: Cache HuggingFace models | |
| uses: actions/cache@v6 | |
| with: | |
| path: ~/.cache/huggingface/hub | |
| key: huggingface-models-v1 | |
| - name: Install ingest dependencies | |
| run: cd ingest && uv sync | |
| - name: Ingest example resume to .mv2 | |
| env: | |
| HF_HUB_DOWNLOAD_TIMEOUT: '120' | |
| run: | | |
| cd ingest | |
| uv run python ingest.py \ | |
| --input ../data/example_resume.md \ | |
| --output /tmp/test_resume.mv2 \ | |
| --verify \ | |
| --quiet | |
| - name: Setup Node.js (for memvid-cli) | |
| uses: actions/setup-node@v7 | |
| with: | |
| node-version: ${{ env.NODE_VERSION }} | |
| # Workaround: memvid Bug C (#196) -- ask() time-index corruption. | |
| # find(mode="hybrid") is unaffected but ask() still fails without this. | |
| # Disable by setting repository variable REBUILD_TIME_INDEX=false. | |
| - name: Rebuild .mv2 time index (Bug C workaround) | |
| if: ${{ vars.REBUILD_TIME_INDEX != 'false' }} | |
| run: npx -y memvid-cli@2.0.158 doctor --rebuild-time-index /tmp/test_resume.mv2 | |
| - name: Install cargo-tarpaulin | |
| run: cargo install cargo-tarpaulin | |
| - name: Integration tests with coverage | |
| env: | |
| TEST_MV2_PATH: /tmp/test_resume.mv2 | |
| working-directory: memvid-service | |
| run: cargo tarpaulin --config tarpaulin-integration.toml | |
| # Cross-Service Integration Tests: API + gRPC (mock backends) | |
| cross-service: | |
| needs: [changes, api-service, memvid-service] | |
| if: >- | |
| always() && | |
| needs.changes.result == 'success' && | |
| (needs.changes.outputs.api-service == 'true' || needs.changes.outputs.memvid-service == 'true') && | |
| needs.api-service.result != 'failure' && | |
| needs.memvid-service.result != 'failure' | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - name: Install uv | |
| uses: astral-sh/setup-uv@v7 | |
| with: | |
| enable-cache: true | |
| - name: Setup Rust | |
| uses: dtolnay/rust-toolchain@master | |
| with: | |
| toolchain: '1.96.0' | |
| - name: Cache cargo | |
| uses: Swatinem/rust-cache@v2 | |
| with: | |
| workspaces: memvid-service | |
| - name: Install protobuf compiler | |
| run: sudo apt-get update && sudo apt-get install -y protobuf-compiler | |
| - name: Install dependencies | |
| run: cd api-service && uv sync --extra test | |
| - name: Run API endpoint tests (mocked backends) | |
| run: cd api-service && uv run pytest tests/test_e2e_api.py -v --tb=short | |
| - name: Build memvid-service binary | |
| run: cd memvid-service && cargo build --release | |
| - name: Run cross-service gRPC integration (mock search) | |
| run: | | |
| chmod +x scripts/test-e2e-integration.sh | |
| ./scripts/test-e2e-integration.sh | |
| # True E2E: Real ingest -> real memvid search -> real API (mock LLM only) | |
| e2e-real: | |
| needs: [changes, api-service, memvid-service, ingest] | |
| if: >- | |
| always() && | |
| needs.changes.result == 'success' && | |
| (needs.changes.outputs.api-service == 'true' || needs.changes.outputs.memvid-service == 'true' || needs.changes.outputs.ingest == 'true') && | |
| needs.api-service.result != 'failure' && | |
| needs.memvid-service.result != 'failure' && | |
| needs.ingest.result != 'failure' | |
| runs-on: ubuntu-latest | |
| continue-on-error: true | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - name: Install uv | |
| uses: astral-sh/setup-uv@v7 | |
| with: | |
| enable-cache: true | |
| - name: Setup Rust | |
| uses: dtolnay/rust-toolchain@master | |
| with: | |
| toolchain: '1.96.0' | |
| - name: Cache cargo | |
| uses: Swatinem/rust-cache@v2 | |
| with: | |
| workspaces: memvid-service | |
| - name: Install protobuf compiler | |
| run: sudo apt-get update && sudo apt-get install -y protobuf-compiler | |
| - name: Cache HuggingFace models | |
| uses: actions/cache@v6 | |
| with: | |
| path: ~/.cache/huggingface/hub | |
| key: huggingface-models-v1 | |
| - name: Install ingest dependencies | |
| run: cd ingest && uv sync --extra test | |
| - name: Install api-service dependencies | |
| run: cd api-service && uv sync --extra test | |
| - name: Setup Node.js (for memvid-cli) | |
| uses: actions/setup-node@v7 | |
| with: | |
| node-version: ${{ env.NODE_VERSION }} | |
| - name: Build memvid-service | |
| run: cd memvid-service && cargo build --release | |
| - name: Run true E2E tests | |
| env: | |
| HF_HUB_DOWNLOAD_TIMEOUT: '120' | |
| REBUILD_TIME_INDEX: ${{ vars.REBUILD_TIME_INDEX || 'true' }} | |
| run: | | |
| chmod +x scripts/test-e2e-real.sh | |
| ./scripts/test-e2e-real.sh | |
| # Commit message standards (PRs only) | |
| commit-standards: | |
| if: github.event_name == 'pull_request' | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v7 | |
| with: | |
| fetch-depth: 0 | |
| - name: Validate commit messages | |
| run: | | |
| ERRORS="" | |
| WARNINGS="" | |
| COMMIT_COUNT=0 | |
| VALID_TYPES="feat|fix|chore|docs|style|refactor|perf|test|ci|build" | |
| CONVENTIONAL_RE="^(${VALID_TYPES})(\(.+\))?: .+" | |
| # Emoji pattern: common Unicode emoji ranges | |
| EMOJI_RE='[\xF0\x9F]|[\xE2\x9C]|[\xE2\x9A]|[\xE2\xAD]|[\xF0\x9F\x8E]|[\xF0\x9F\x9A]|🎉|🚀|✨|🐛|📝|🔧|♻️|⬆️|⬇️|🔥|💡|✅|❌|🎨|🩹|🔒' | |
| # AI-ism patterns (case-insensitive, checked with grep -iF) | |
| AIISM_PATTERNS=( | |
| "Co-Authored-By:" | |
| "co-authored-by:" | |
| "Anthropic" | |
| "GPT" | |
| "OpenAI" | |
| "As an AI" | |
| "as an AI" | |
| "I have" | |
| "Refined" | |
| "Polished" | |
| ) | |
| BASE_SHA="${{ github.event.pull_request.base.sha }}" | |
| HEAD_SHA="${{ github.event.pull_request.head.sha }}" | |
| while IFS= read -r SUBJECT; do | |
| [ -z "$SUBJECT" ] && continue | |
| COMMIT_COUNT=$((COMMIT_COUNT + 1)) | |
| COMMIT_ERRORS="" | |
| # Check conventional commit format | |
| if ! echo "$SUBJECT" | grep -qE "$CONVENTIONAL_RE"; then | |
| COMMIT_ERRORS="${COMMIT_ERRORS} - NOT conventional commit format (expected: type(scope): description)\n" | |
| fi | |
| # Check for emoji | |
| if echo "$SUBJECT" | grep -qP '[\x{1F300}-\x{1F9FF}\x{2600}-\x{26FF}\x{2700}-\x{27BF}\x{FE00}-\x{FE0F}\x{1F000}-\x{1FFFF}]'; then | |
| COMMIT_ERRORS="${COMMIT_ERRORS} - Contains emoji (not allowed in commit messages)\n" | |
| fi | |
| # Check for AI-isms | |
| for PATTERN in "${AIISM_PATTERNS[@]}"; do | |
| if echo "$SUBJECT" | grep -qF "$PATTERN"; then | |
| COMMIT_ERRORS="${COMMIT_ERRORS} - Contains AI-ism/branding: '${PATTERN}'\n" | |
| fi | |
| done | |
| # Special check for "Claude": block standalone usage but allow "Claude Code" (product name) | |
| # Strip "Claude Code" phrases and "claude" in conventional commit scopes e.g. (claude), (claude_code) | |
| STRIPPED=$(echo "$SUBJECT" | sed 's/[Cc][Ll][Aa][Uu][Dd][Ee] [Cc][Oo][Dd][Ee]//g' | sed 's/([Cc][Ll][Aa][Uu][Dd][Ee][^)]*)//g') | |
| if echo "$STRIPPED" | grep -qi "Claude"; then | |
| COMMIT_ERRORS="${COMMIT_ERRORS} - Contains AI-ism/branding: 'Claude' (note: 'Claude Code' as a product name is allowed)\n" | |
| fi | |
| # Check subject line length (warning only) | |
| SUBJECT_LEN=${#SUBJECT} | |
| if [ "$SUBJECT_LEN" -gt 72 ]; then | |
| WARNINGS="${WARNINGS}WARNING: Subject line too long (${SUBJECT_LEN} chars, recommended max 72):\n ${SUBJECT}\n\n" | |
| fi | |
| if [ -n "$COMMIT_ERRORS" ]; then | |
| ERRORS="${ERRORS}FAIL: ${SUBJECT}\n${COMMIT_ERRORS}\n" | |
| fi | |
| done < <(git log --no-merges --format=%s "${BASE_SHA}..${HEAD_SHA}") | |
| echo "=== Commit Message Validation ===" | |
| echo "Checked ${COMMIT_COUNT} commit(s)" | |
| echo "" | |
| if [ -n "$WARNINGS" ]; then | |
| echo "--- Warnings ---" | |
| printf "$WARNINGS" | |
| fi | |
| if [ -n "$ERRORS" ]; then | |
| echo "--- Errors ---" | |
| printf "$ERRORS" | |
| echo "" | |
| echo "Commit message rules:" | |
| echo " 1. Use conventional commits: type(scope): description" | |
| echo " Valid types: feat, fix, chore, docs, style, refactor, perf, test, ci, build" | |
| echo " 2. No emoji in commit messages" | |
| echo " 3. No AI-isms or branding (Claude, Anthropic, GPT, OpenAI, etc.)" | |
| echo " Exception: 'Claude Code' is allowed as a product name reference" | |
| echo " 4. Subject line should be 72 chars or fewer" | |
| exit 1 | |
| fi | |
| echo "All commit messages passed validation!" | |
| # Release gate: final validation on main branch pushes | |
| release-gate: | |
| needs: [changes, frontend, api-service, memvid-service, cross-service] | |
| if: github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.ref == 'refs/heads/main') | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - name: Install uv | |
| uses: astral-sh/setup-uv@v7 | |
| with: | |
| enable-cache: true | |
| - name: Setup Node.js | |
| uses: actions/setup-node@v7 | |
| with: | |
| node-version: ${{ env.NODE_VERSION }} | |
| - name: Install frontend dependencies | |
| run: cd frontend && npm ci | |
| - name: Install ingest dependencies | |
| run: cd ingest && uv sync --extra test | |
| - name: Install api-service dependencies | |
| run: cd api-service && uv sync --extra test | |
| - name: Outcome tests -- Factual Accuracy | |
| run: cd api-service && uv run pytest tests/test_outcome_factual.py -v --tb=short | |
| - name: Outcome tests -- Negative Testing | |
| run: cd api-service && uv run pytest tests/test_outcome_negative.py -v --tb=short | |
| - name: Outcome tests -- Honesty & Gaps | |
| run: cd api-service && uv run pytest tests/test_outcome_honesty.py -v --tb=short | |
| - name: Outcome tests -- Security Guardrails | |
| run: cd api-service && uv run pytest tests/test_outcome_security.py -v --tb=short | |
| - name: Outcome tests -- Injection Scenarios | |
| run: cd api-service && uv run pytest tests/test_injection_scenarios.py -v --tb=short | |
| - name: Outcome tests -- Stream Leakage | |
| run: cd api-service && uv run pytest tests/test_stream_leakage.py -v --tb=short | |
| - name: Outcome tests -- E2E Quality | |
| run: cd api-service && uv run pytest tests/test_e2e_quality.py -v --tb=short -m e2e | |
| - name: Outcome tests -- Data Coverage (ingest) | |
| env: | |
| HF_HUB_DOWNLOAD_TIMEOUT: '120' | |
| run: cd ingest && uv run pytest tests/test_outcome_data_coverage.py -v --tb=short -m slow --no-cov | |
| - name: Outcome tests -- Portability | |
| run: bash scripts/test-outcome-portability.sh | |
| - name: Outcome tests -- Container Config | |
| run: bash scripts/test-outcome-containers.sh | |
| - name: Release gate passed | |
| run: echo "Release gate passed - all blocking outcome gates succeeded" | |
| # Container builds: 4 images x 2 platforms (native runners) | |
| container-build: | |
| needs: changes | |
| if: ${{ needs.changes.outputs.containers == 'true' || startsWith(github.ref, 'refs/tags/v') }} | |
| runs-on: ${{ matrix.platform == 'amd64' && 'ubuntu-latest' || 'ubuntu-24.04-arm' }} | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| image: [frontend, api, memvid, ingest] | |
| platform: [amd64, arm64] | |
| env: | |
| IMAGE_NAME: ai-resume-${{ matrix.image }} | |
| REGISTRY: ghcr.io/${{ github.repository_owner }} | |
| VERSION: ${{ github.ref_name }} | |
| COSIGN_YES: 'true' | |
| steps: | |
| - uses: actions/checkout@v7 | |
| # The ingest container drags in the full torch/nvidia CUDA wheel stack via | |
| # sentence-transformers (~5-6 GB unpacked), which exhausts the ~14 GB of | |
| # free disk on the ubuntu-latest amd64 runner while podman stores the | |
| # `.venv` layer blob under /var/tmp. Reclaim as much as possible by purging | |
| # preinstalled toolchains, large apt packages, and the swapfile -- none are | |
| # needed for a container build. Arm runner has more headroom and skips this | |
| # step. Tracked: alpha.18 post-mortem; reclaim maxed in alpha.23. | |
| - name: Free disk space on amd64 ingest runner | |
| if: matrix.image == 'ingest' && matrix.platform == 'amd64' | |
| uses: jlumbroso/free-disk-space@v1.3.1 | |
| with: | |
| tool-cache: true | |
| android: true | |
| dotnet: true | |
| haskell: true | |
| large-packages: true | |
| swap-storage: true | |
| - name: Install cosign | |
| if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')) | |
| uses: sigstore/cosign-installer@v3 | |
| - name: Install syft | |
| if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')) | |
| uses: anchore/sbom-action/download-syft@v0 | |
| - name: Set version for non-tag refs | |
| run: | | |
| if [[ "$VERSION" != v* ]]; then | |
| BUILD_TS=$(date -u +%Y%m%d%H%M%S) | |
| VERSION="dev-$(git rev-parse --short HEAD)-B${BUILD_TS}" | |
| echo "VERSION=$VERSION" >> "$GITHUB_ENV" | |
| fi | |
| - name: Sync proto files | |
| if: matrix.image == 'memvid' || matrix.image == 'api' | |
| run: | | |
| SERVICE=${{ matrix.image == 'memvid' && 'memvid-service' || 'api-service' }} | |
| mkdir -p "$SERVICE/proto/memvid/v1" | |
| cp proto/memvid/v1/memvid.proto "$SERVICE/proto/memvid/v1/" | |
| - name: Determine build context | |
| id: ctx | |
| run: | | |
| case "${{ matrix.image }}" in | |
| frontend) echo "dir=frontend" >> "$GITHUB_OUTPUT" ;; | |
| api) echo "dir=api-service" >> "$GITHUB_OUTPUT" ;; | |
| memvid) echo "dir=memvid-service" >> "$GITHUB_OUTPUT" ;; | |
| ingest) echo "dir=ingest" >> "$GITHUB_OUTPUT" ;; | |
| esac | |
| - name: Build container image | |
| env: | |
| BUILD_DATE: ${{ github.event.head_commit.timestamp || github.event.pull_request.updated_at }} | |
| run: | | |
| podman build \ | |
| --tag "localhost/${IMAGE_NAME}:${VERSION}" \ | |
| --build-arg "BUILD_VERSION=${VERSION}" \ | |
| --build-arg "BUILD_COMMIT=${{ github.sha }}" \ | |
| --annotation "org.opencontainers.image.title=${IMAGE_NAME}" \ | |
| --annotation "org.opencontainers.image.source=https://github.com/${{ github.repository }}" \ | |
| --annotation "org.opencontainers.image.version=${VERSION}" \ | |
| --annotation "org.opencontainers.image.created=${BUILD_DATE}" \ | |
| --annotation "org.opencontainers.image.revision=${{ github.sha }}" \ | |
| --annotation "org.opencontainers.image.licenses=PolyForm-Noncommercial-1.0.0 OR LicenseRef-Commercial" \ | |
| --annotation "org.opencontainers.image.vendor=${{ github.repository_owner }}" \ | |
| -f "${{ steps.ctx.outputs.dir }}/Dockerfile" \ | |
| "${{ steps.ctx.outputs.dir }}/" | |
| - name: Start podman API for scanners | |
| run: | | |
| podman system service --time=0 "unix:///tmp/podman.sock" & | |
| timeout 10 bash -c 'until [ -S /tmp/podman.sock ]; do sleep 0.5; done' | |
| echo "DOCKER_HOST=unix:///tmp/podman.sock" >> "$GITHUB_ENV" | |
| - name: Trivy scan (SARIF for summary) | |
| uses: aquasecurity/trivy-action@v0.36.0 | |
| with: | |
| image-ref: 'localhost/${{ env.IMAGE_NAME }}:${{ env.VERSION }}' | |
| format: 'sarif' | |
| output: 'trivy-${{ matrix.image }}-${{ matrix.platform }}.sarif' | |
| trivyignores: '.trivyignore' | |
| - name: Trivy severity gate (CRITICAL/HIGH) | |
| uses: aquasecurity/trivy-action@v0.36.0 | |
| with: | |
| image-ref: 'localhost/${{ env.IMAGE_NAME }}:${{ env.VERSION }}' | |
| format: 'table' | |
| exit-code: '1' | |
| severity: 'CRITICAL,HIGH' | |
| ignore-unfixed: true | |
| trivyignores: '.trivyignore' | |
| - name: Container smoke tests | |
| run: | | |
| echo "--- Smoke testing ${IMAGE_NAME} on ${{ matrix.platform }} ---" | |
| # Determine health check port and path | |
| case "${{ matrix.image }}" in | |
| frontend) PORT=8080; HEALTH_PATH="/health" ;; | |
| api) PORT=3000; HEALTH_PATH="/health" ;; | |
| memvid) PORT=50051; HEALTH_PATH="" ;; | |
| ingest) PORT=""; HEALTH_PATH="" ;; | |
| esac | |
| # Test 1: Image runs | |
| CID=$(podman run -d --name smoke-test "localhost/${IMAGE_NAME}:${VERSION}") | |
| # Test 2: Non-root user check | |
| USER=$(podman exec "$CID" whoami 2>/dev/null || podman exec "$CID" id -u 2>/dev/null || echo "unknown") | |
| echo "Container user: $USER" | |
| if [ "$USER" = "root" ] || [ "$USER" = "0" ]; then | |
| echo "::warning::Container runs as root" | |
| fi | |
| # Test 3: Health check (if applicable) | |
| if [ -n "$PORT" ] && [ -n "$HEALTH_PATH" ]; then | |
| sleep 3 | |
| case "${{ matrix.image }}" in | |
| api) | |
| # ubi-micro has no wget/curl; use the healthcheck script | |
| podman exec "$CID" /healthcheck && echo "Health check: OK" || echo "Health check: FAIL" | |
| ;; | |
| frontend) | |
| # Alpine has wget | |
| STATUS=$(podman exec "$CID" wget -q -O /dev/null -S "http://localhost:${PORT}${HEALTH_PATH}" 2>&1 | grep "HTTP/" | tail -1 | awk '{print $2}') || true | |
| echo "Health check status: ${STATUS:-no response}" | |
| ;; | |
| esac | |
| fi | |
| # Test 4: OCI annotations (stored in manifest, not Docker labels) | |
| podman image inspect "localhost/${IMAGE_NAME}:${VERSION}" --format '{{ index .Annotations "org.opencontainers.image.version" }}' | grep -q "${VERSION}" && echo "OCI version annotation: OK" || echo "::warning::OCI version annotation missing" | |
| # Cleanup | |
| podman rm -f smoke-test || true | |
| - name: Login to ghcr.io | |
| if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')) | |
| run: | | |
| echo "${{ secrets.GITHUB_TOKEN }}" | podman login ghcr.io -u "${{ github.actor }}" --password-stdin | |
| echo "${{ secrets.GITHUB_TOKEN }}" | cosign login ghcr.io -u "${{ github.actor }}" --password-stdin | |
| - name: Push arch-specific image | |
| id: push | |
| if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')) | |
| run: | | |
| chmod +x scripts/publish-ci.sh | |
| OUTPUT=$(scripts/publish-ci.sh push-arch "$REGISTRY" "$IMAGE_NAME" "$VERSION" "${{ matrix.platform }}") | |
| echo "$OUTPUT" | |
| DIGEST=$(echo "$OUTPUT" | grep '^DIGEST=' | cut -d= -f2) | |
| echo "digest=$DIGEST" >> "$GITHUB_OUTPUT" | |
| - name: Create digest metadata | |
| if: steps.push.outputs.digest != '' | |
| run: | | |
| cat > /tmp/digest-metadata.json <<ENDJSON | |
| { | |
| "image": "${{ env.IMAGE_NAME }}", | |
| "platform": "${{ matrix.platform }}", | |
| "digest": "${{ steps.push.outputs.digest }}", | |
| "version": "${{ env.VERSION }}" | |
| } | |
| ENDJSON | |
| - name: Upload digest metadata | |
| if: steps.push.outputs.digest != '' | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: digest-${{ matrix.image }}-${{ matrix.platform }} | |
| path: /tmp/digest-metadata.json | |
| retention-days: 1 | |
| - name: Generate SBOM | |
| if: steps.push.outputs.digest != '' | |
| run: | | |
| syft "${REGISTRY}/${IMAGE_NAME}@${{ steps.push.outputs.digest }}" \ | |
| -o cyclonedx-json=/tmp/sbom-${{ matrix.image }}-${{ matrix.platform }}.json | |
| - name: Attest SBOM | |
| if: steps.push.outputs.digest != '' | |
| run: | | |
| cosign attest --type cyclonedx \ | |
| --predicate /tmp/sbom-${{ matrix.image }}-${{ matrix.platform }}.json \ | |
| "${REGISTRY}/${IMAGE_NAME}@${{ steps.push.outputs.digest }}" | |
| - name: Sign image | |
| if: steps.push.outputs.digest != '' | |
| run: | | |
| cosign sign "${REGISTRY}/${IMAGE_NAME}@${{ steps.push.outputs.digest }}" | |
| - name: Upload SBOM artifact | |
| if: steps.push.outputs.digest != '' | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: sbom-${{ matrix.image }}-${{ matrix.platform }} | |
| path: /tmp/sbom-${{ matrix.image }}-${{ matrix.platform }}.json | |
| retention-days: 90 | |
| - name: Write build summary | |
| if: always() | |
| run: | | |
| { | |
| echo "## Container: ${IMAGE_NAME} (${{ matrix.platform }})" | |
| echo "" | |
| # Image size | |
| SIZE=$(podman image inspect "localhost/${IMAGE_NAME}:${VERSION}" --format '{{.Size}}' 2>/dev/null || echo "0") | |
| SIZE_MB=$((SIZE / 1024 / 1024)) | |
| # Trivy results from SARIF | |
| TRIVY_SARIF="trivy-${{ matrix.image }}-${{ matrix.platform }}.sarif" | |
| if [ -f "$TRIVY_SARIF" ]; then | |
| CRITICAL=$(jq '[.runs[].results[] | select(.level == "error")] | length' "$TRIVY_SARIF" 2>/dev/null || echo "?") | |
| HIGH=$(jq '[.runs[].results[] | select(.level == "warning")] | length' "$TRIVY_SARIF" 2>/dev/null || echo "?") | |
| else | |
| CRITICAL="?" | |
| HIGH="?" | |
| fi | |
| # Push status | |
| DIGEST="${{ steps.push.outputs.digest }}" | |
| PUSH_STATUS="${DIGEST:-skipped (PR)}" | |
| echo "| Metric | Value |" | |
| echo "|--------|-------|" | |
| echo "| Image | ${IMAGE_NAME} |" | |
| echo "| Platform | ${{ matrix.platform }} |" | |
| echo "| Size | ${SIZE_MB} MB |" | |
| echo "| Trivy Critical | ${CRITICAL} |" | |
| echo "| Trivy High | ${HIGH} |" | |
| echo "| Push | ${PUSH_STATUS} |" | |
| } >> "$GITHUB_STEP_SUMMARY" || true | |
| shell: bash | |
| # Summary job (always runs, reports overall status) | |
| summary: | |
| needs: | |
| [ | |
| base, | |
| changes, | |
| frontend, | |
| api-service, | |
| ingest, | |
| memvid-service, | |
| memvid-integration, | |
| cross-service, | |
| e2e-real, | |
| commit-standards, | |
| container-build, | |
| release-gate, | |
| ] | |
| if: always() | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Check results | |
| run: | | |
| echo "=== CI Summary ===" | |
| echo "Base (lint): ${{ needs.base.result || 'skipped' }}" | |
| echo "Frontend: ${{ needs.frontend.result || 'skipped' }}" | |
| echo "API Service: ${{ needs.api-service.result || 'skipped' }}" | |
| echo "Ingest: ${{ needs.ingest.result || 'skipped' }}" | |
| echo "Memvid Service: ${{ needs.memvid-service.result || 'skipped' }}" | |
| echo "Memvid Integration: ${{ needs.memvid-integration.result || 'skipped' }}" | |
| echo "Cross-Service: ${{ needs.cross-service.result || 'skipped' }}" | |
| echo "E2E Real: ${{ needs.e2e-real.result || 'skipped' }}" | |
| echo "Commit Standards: ${{ needs.commit-standards.result || 'skipped' }}" | |
| echo "Container Build: ${{ needs.container-build.result || 'skipped' }}" | |
| echo "Release Gate: ${{ needs.release-gate.result || 'skipped' }}" | |
| echo "" | |
| echo "=== Coverage Gate ===" | |
| echo "Coverage thresholds enforced per-service:" | |
| echo " Frontend: 10% (vitest thresholds, ramping to 85%)" | |
| echo " Ingest: 85% (pytest --cov-fail-under)" | |
| echo " Memvid (unit): 85% (cargo tarpaulin --fail-under, excludes real.rs)" | |
| echo " Memvid (integration): 50% (cargo tarpaulin on real.rs, soft gate)" | |
| # Fail if any required job failed (includes coverage threshold enforcement) | |
| if [[ "${{ needs.base.result }}" == "failure" ]] || \ | |
| [[ "${{ needs.frontend.result }}" == "failure" ]] || \ | |
| [[ "${{ needs.api-service.result }}" == "failure" ]] || \ | |
| [[ "${{ needs.ingest.result }}" == "failure" ]] || \ | |
| [[ "${{ needs.memvid-service.result }}" == "failure" ]] || \ | |
| [[ "${{ needs.cross-service.result }}" == "failure" ]] || \ | |
| [[ "${{ needs.commit-standards.result }}" == "failure" ]] || \ | |
| [[ "${{ needs.container-build.result }}" == "failure" ]] || \ | |
| [[ "${{ needs.release-gate.result }}" == "failure" ]]; then | |
| echo "" | |
| echo "One or more jobs failed!" | |
| exit 1 | |
| fi | |
| # Write aggregated summary | |
| { | |
| echo "## CI Summary" | |
| echo "" | |
| echo "| Job | Status |" | |
| echo "|-----|--------|" | |
| echo "| Base (lint) | ${{ needs.base.result || 'skipped' }} |" | |
| echo "| Frontend | ${{ needs.frontend.result || 'skipped' }} |" | |
| echo "| API Service | ${{ needs.api-service.result || 'skipped' }} |" | |
| echo "| Ingest | ${{ needs.ingest.result || 'skipped' }} |" | |
| echo "| Memvid Service | ${{ needs.memvid-service.result || 'skipped' }} |" | |
| echo "| Memvid Integration | ${{ needs.memvid-integration.result || 'skipped' }} |" | |
| echo "| Cross-Service | ${{ needs.cross-service.result || 'skipped' }} |" | |
| echo "| E2E Real | ${{ needs.e2e-real.result || 'skipped' }} |" | |
| echo "| Commit Standards | ${{ needs.commit-standards.result || 'skipped' }} |" | |
| echo "| Container Build | ${{ needs.container-build.result || 'skipped' }} |" | |
| echo "| Release Gate | ${{ needs.release-gate.result || 'skipped' }} |" | |
| echo "" | |
| echo "### Container Build Matrix" | |
| echo "" | |
| echo "See individual container-build job summaries for image size, Trivy findings, and push status." | |
| } >> "$GITHUB_STEP_SUMMARY" || true | |
| echo "" | |
| echo "All checks passed (including coverage gates)!" | |
| - name: Post PR comment | |
| if: github.event_name == 'pull_request' | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| run: | | |
| # Map result to status indicator | |
| status_icon() { | |
| case "$1" in | |
| success) echo "pass" ;; | |
| failure) echo "FAIL" ;; | |
| skipped) echo "skip" ;; | |
| *) echo "$1" ;; | |
| esac | |
| } | |
| BASE=$(status_icon "${{ needs.base.result || 'skipped' }}") | |
| FRONTEND=$(status_icon "${{ needs.frontend.result || 'skipped' }}") | |
| API=$(status_icon "${{ needs.api-service.result || 'skipped' }}") | |
| INGEST=$(status_icon "${{ needs.ingest.result || 'skipped' }}") | |
| MEMVID=$(status_icon "${{ needs.memvid-service.result || 'skipped' }}") | |
| MEMVID_INT=$(status_icon "${{ needs.memvid-integration.result || 'skipped' }}") | |
| CROSS=$(status_icon "${{ needs.cross-service.result || 'skipped' }}") | |
| E2E=$(status_icon "${{ needs.e2e-real.result || 'skipped' }}") | |
| COMMITS=$(status_icon "${{ needs.commit-standards.result || 'skipped' }}") | |
| CONTAINERS=$(status_icon "${{ needs.container-build.result || 'skipped' }}") | |
| RELEASE=$(status_icon "${{ needs.release-gate.result || 'skipped' }}") | |
| # Determine overall status | |
| OVERALL="All checks passed" | |
| if [[ "${{ needs.base.result }}" == "failure" ]] || \ | |
| [[ "${{ needs.frontend.result }}" == "failure" ]] || \ | |
| [[ "${{ needs.api-service.result }}" == "failure" ]] || \ | |
| [[ "${{ needs.ingest.result }}" == "failure" ]] || \ | |
| [[ "${{ needs.memvid-service.result }}" == "failure" ]] || \ | |
| [[ "${{ needs.cross-service.result }}" == "failure" ]] || \ | |
| [[ "${{ needs.commit-standards.result }}" == "failure" ]] || \ | |
| [[ "${{ needs.container-build.result }}" == "failure" ]] || \ | |
| [[ "${{ needs.release-gate.result }}" == "failure" ]]; then | |
| OVERALL="One or more checks failed" | |
| fi | |
| RUN_URL="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" | |
| BODY=$(cat <<ENDMD | |
| ### CI Summary | |
| | Job | Status | | |
| |-----|--------| | |
| | Base (lint) | ${BASE} | | |
| | Frontend | ${FRONTEND} | | |
| | API Service | ${API} | | |
| | Ingest | ${INGEST} | | |
| | Memvid Service | ${MEMVID} | | |
| | Memvid Integration | ${MEMVID_INT} | | |
| | Cross-Service | ${CROSS} | | |
| | E2E Real | ${E2E} | | |
| | Commit Standards | ${COMMITS} | | |
| | Container Build | ${CONTAINERS} | | |
| | Release Gate | ${RELEASE} | | |
| **${OVERALL}** | [Full run details](${RUN_URL}) | |
| ENDMD | |
| ) | |
| PR_NUMBER="${{ github.event.pull_request.number }}" | |
| if [ -n "$PR_NUMBER" ]; then | |
| # Delete previous CI summary comment if exists (sticky comment pattern) | |
| PREV_COMMENT=$(gh api "repos/${{ github.repository }}/issues/${PR_NUMBER}/comments" \ | |
| --jq '.[] | select(.user.login == "github-actions[bot]" and (.body | contains("### CI Summary"))) | .id' | tail -1) | |
| if [ -n "$PREV_COMMENT" ]; then | |
| gh api "repos/${{ github.repository }}/issues/comments/${PREV_COMMENT}" -X DELETE || true | |
| fi | |
| gh pr comment "$PR_NUMBER" --repo "${{ github.repository }}" --body "$BODY" | |
| fi | |
| shell: bash |