Skip to content

build(deps): bump the minor-and-patch group in /frontend with 5 updates #898

build(deps): bump the minor-and-patch group in /frontend with 5 updates

build(deps): bump the minor-and-patch group in /frontend with 5 updates #898

Workflow file for this run

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