Skip to content

chore(main): release 0.4.6 #837

chore(main): release 0.4.6

chore(main): release 0.4.6 #837

Workflow file for this run

name: Post-Commit PR Gates
on:
pull_request:
branches: [main, develop]
types: [opened, edited, synchronize, reopened]
concurrency:
group: pr-gates-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
permissions:
contents: read
pull-requests: write
jobs:
# ── Fast checks: no Django deps needed ────────────────────────────────────
pr-title:
name: "📝 PR Title"
runs-on: ubuntu-latest
steps:
- name: Validate PR title
uses: actions/github-script@v9
with:
script: |
const title = context.payload.pull_request.title;
if (/^(WIP:|\[WIP\])\s/i.test(title)) { return; }
const types = ['feat', 'fix', 'docs', 'style', 'refactor', 'perf', 'test', 'build', 'ci', 'chore', 'revert'];
const pattern = new RegExp(`^(${types.join('|')})(\\([a-z0-9/._-]+\\))?!?:\\s[a-z].+`);
if (!pattern.test(title)) {
core.setFailed(`PR title "${title}" does not follow Conventional Commits. Expected: type(scope): description`);
}
lint:
name: "🔍 Lint"
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: ./.github/actions/python-setup
- name: Install dependencies
run: pip install ruff==0.15.20
- name: Ruff
run: ruff check .
- name: Ruff format
run: ruff format --check .
secrets:
name: "🔑 Secrets"
runs-on: ubuntu-latest
permissions:
contents: read
security-events: write
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Gitleaks
uses: gitleaks/gitleaks-action@e0c47f4f8be36e29cdc102c57e68cb5cbf0e8d1e # v3
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# ── Django system checks (no test runner, just verification) ──────────────
django-checks:
name: "🩺 Django Checks"
runs-on: ubuntu-latest
env:
DJANGO_SETTINGS_MODULE: investor_app.settings_test
SECRET_KEY: ci-checks-django
DEBUG: "True"
DATABASE_URL: "sqlite:///tmp/ci_check_db.sqlite3"
steps:
- uses: actions/checkout@v7
- uses: ./.github/actions/python-setup
- name: Install dependencies
run: pip install -r requirements.txt
- name: System checks
run: python manage.py check
- name: Missing migrations
run: python manage.py makemigrations --check --dry-run --noinput
- name: Static files
run: python manage.py collectstatic --noinput --verbosity 0
# ── Test matrix: parallel by layer ────────────────────────────────────────
tests-unit:
name: "🧪 Unit Tests"
runs-on: ubuntu-latest
env:
DJANGO_SETTINGS_MODULE: investor_app.settings_test
steps:
- uses: actions/checkout@v7
- uses: ./.github/actions/python-setup
- name: Install system deps
run: sudo apt-get update && sudo apt-get install -y libcairo2-dev
- name: Install dependencies
run: pip install -r requirements.txt coverage
- name: Collect static files
run: python manage.py collectstatic --noinput --verbosity 0
- name: Run unit tests
run: |
export SECRET_KEY=$(python -c 'import secrets; print(secrets.token_hex(50))')
coverage run --parallel-mode -m pytest tests/ core/tests/ -m unit \
-q --tb=short
- name: Flaky test report
if: always()
run: python .github/scripts/flaky_report.py --report-log .pytest-report.jsonl --mode report --fail-over 2
- name: Upload coverage data
uses: actions/upload-artifact@v7
with:
name: coverage-unit
path: .coverage.*
include-hidden-files: true
retention-days: 1
- name: Upload test report log
if: always()
uses: actions/upload-artifact@v7
with:
name: report-log-unit
path: .pytest-report.jsonl
retention-days: 1
tests-integration:
name: "🔗 Integration Tests"
runs-on: ubuntu-latest
env:
DJANGO_SETTINGS_MODULE: investor_app.settings_test
steps:
- uses: actions/checkout@v7
- uses: ./.github/actions/python-setup
- name: Install system deps
run: sudo apt-get update && sudo apt-get install -y libcairo2-dev
- name: Install dependencies
run: pip install -r requirements.txt coverage
- name: Cache Playwright browsers
id: playwright-cache
uses: actions/cache@v6
with:
path: ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-1.62.0
- name: Install Playwright
run: playwright install --with-deps chromium
if: steps.playwright-cache.outputs.cache-hit != 'true'
- name: Install Playwright system deps only
run: playwright install-deps chromium
if: steps.playwright-cache.outputs.cache-hit == 'true'
- name: Collect static files
run: python manage.py collectstatic --noinput --verbosity 0
- name: Run integration tests
run: |
export SECRET_KEY=$(python -c 'import secrets; print(secrets.token_hex(50))')
coverage run --parallel-mode -m pytest tests/ core/tests/ -m integration \
-q --tb=short
- name: Flaky test report
if: always()
run: python .github/scripts/flaky_report.py --report-log .pytest-report.jsonl --mode report --fail-over 2
- name: Upload coverage data
uses: actions/upload-artifact@v7
with:
name: coverage-integration
path: .coverage.*
include-hidden-files: true
retention-days: 1
- name: Upload test report log
if: always()
uses: actions/upload-artifact@v7
with:
name: report-log-integration
path: .pytest-report.jsonl
retention-days: 1
tests-e2e:
name: "🌐 E2E Tests"
runs-on: ubuntu-latest
env:
DJANGO_SETTINGS_MODULE: investor_app.settings_test
steps:
- uses: actions/checkout@v7
- uses: ./.github/actions/python-setup
- name: Install system deps
run: sudo apt-get update && sudo apt-get install -y libcairo2-dev
- name: Install dependencies
run: pip install -r requirements.txt coverage
- name: Install Playwright
run: playwright install --with-deps chromium
- name: Collect static files
run: python manage.py collectstatic --noinput --verbosity 0
- name: Run E2E tests
run: |
export SECRET_KEY=$(python -c 'import secrets; print(secrets.token_hex(50))')
coverage run --parallel-mode -m pytest tests/ core/tests/ -m e2e \
-q --tb=short
- name: Flaky test report
if: always()
run: python .github/scripts/flaky_report.py --report-log .pytest-report.jsonl --mode report --fail-over 2
- name: Upload coverage data
uses: actions/upload-artifact@v7
with:
name: coverage-e2e
path: .coverage.*
include-hidden-files: true
retention-days: 1
- name: Upload test report log
if: always()
uses: actions/upload-artifact@v7
with:
name: report-log-e2e
path: .pytest-report.jsonl
retention-days: 1
typecheck:
name: "🔷 Typecheck"
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: ./.github/actions/python-setup
- name: Install system deps
run: sudo apt-get update && sudo apt-get install -y libcairo2-dev
- name: Install dependencies
run: pip install -r requirements.txt
- name: Mypy
run: mypy core/ investor_app/finance/ --ignore-missing-imports --disable-error-code var-annotated --disable-error-code attr-defined --disable-error-code operator --disable-error-code misc --disable-error-code has-type --disable-error-code arg-type --disable-error-code assignment
# ── Lighthouse PWA audit ────────────────────────────────────────────────
lighthouse:
name: "🚀 Lighthouse PWA Audit"
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: ./.github/actions/python-setup
- name: Install system deps
run: sudo apt-get update && sudo apt-get install -y libcairo2-dev
- name: Install dependencies
run: pip install -r requirements.txt
- name: Collect static files
run: python manage.py collectstatic --noinput --verbosity 0
- name: Install Lighthouse CI
run: npm install -g @lhci/cli
- name: Run Lighthouse CI
continue-on-error: true
run: |
export SECRET_KEY=$(python -c 'import secrets; print(secrets.token_hex(50))')
export DATABASE_URL="sqlite:///tmp/lighthouse_test.db"
export RUN_MIGRATIONS=1
export SKIP_SEED=1
export DJANGO_ENV=development
export DEBUG=True
export ALLOWED_HOSTS=localhost,127.0.0.1
# Start Django server in background
python manage.py runserver 0.0.0.0:8000 &
SERVER_PID=$!
# Wait for server to start
for i in $(seq 1 30); do
if curl -s http://localhost:8000/health/ > /dev/null 2>&1; then
echo "Server started"
break
fi
sleep 2
done
# Run Lighthouse CI
lhci autorun --config=lighthouserc.json --collect.staticDistDir=./staticfiles
# Stop server
kill $SERVER_PID 2>/dev/null || true
- name: Upload Lighthouse results
uses: actions/upload-artifact@v7
if: always()
with:
name: lighthouse-results
path: .lighthouseci/
retention-days: 7
# ── Acceptance tests (real HTTP, via pytest-django live_server) ────────────
# BASE_URL is intentionally unset here: tests/acceptance/conftest.py falls
# back to a live_server for this run. Full acceptance tests also run
# post-deployment against the actual deployed artifact (BASE_URL set there).
acceptance-check:
name: "🧪 Acceptance Tests"
runs-on: ubuntu-latest
env:
SECRET_KEY: ci-acceptance-check-key
DATABASE_URL: sqlite:////tmp/acceptance_check.sqlite3
DJANGO_SETTINGS_MODULE: investor_app.settings_test
steps:
- uses: actions/checkout@v7
- uses: ./.github/actions/python-setup
- name: Install test deps
run: |
pip install -r requirements.txt -q
pip install httpx pydantic pytest-django -q
- name: Collect static files
run: python manage.py collectstatic --noinput --verbosity 0
- name: Run acceptance tests against live_server
run: python -m pytest tests/acceptance/ -q --tb=short -m acceptance
# ── Financial math verification ────────────────────────────────────────────
finance-math:
name: "💰 Financial Math"
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: ./.github/actions/python-setup
- name: Install deps
run: pip install -r requirements.txt -q
- name: Verify KPI calculations
run: python -m pytest tests/test_finance_math.py -v --tb=short
# ── Authenticated security scan (ephemeral CI instance, not live deploy) ──
# See docs/KNOWN_LIMITATIONS.md LIMIT-22: this scans a freshly-migrated,
# freshly-seeded CI-only instance, not the real deployment. The existing
# unauthenticated full scan in post-deployment.yml still runs against the
# live artifact and is unchanged.
zap-authenticated-scan:
name: "🛡️ Authenticated ZAP Scan"
runs-on: ubuntu-latest
env:
DJANGO_SETTINGS_MODULE: investor_app.settings_test
SECRET_KEY: ci-zap-scan-key
DATABASE_URL: "sqlite:////tmp/zap_scan.sqlite3"
ZAP_AUTH_USERNAME: zap-ci-scan-only
steps:
- uses: actions/checkout@v7
- uses: ./.github/actions/python-setup
- name: Generate throwaway scan credential
run: |
echo "ZAP_AUTH_PASSWORD=$(python3 -c 'import secrets; print(secrets.token_urlsafe(24))')" >> "$GITHUB_ENV"
- name: Install dependencies
run: pip install -r requirements.txt -q
- name: Migrate
run: python manage.py migrate --noinput
- name: Collect static files
run: python manage.py collectstatic --noinput --verbosity 0
- name: Seed ZAP scan user
run: python manage.py seed_zap_scan_user
- name: Render ZAP auth context
run: |
CREDS_B64=$(printf 'username=%s&password=%s' "$ZAP_AUTH_USERNAME" "$ZAP_AUTH_PASSWORD" | base64 -w0)
sed "s/__ZAP_AUTH_CREDS_B64__/$CREDS_B64/" .zap/prei-auth-context.xml > .zap/prei-auth-context-runtime.xml
- name: Start server
run: nohup python manage.py runserver 0.0.0.0:8000 > server.log 2>&1 &
- name: Wait for healthy
run: |
set -e
HEALTHY=0
for i in $(seq 1 60); do
STATUS=$(curl -sf -o /dev/null -w "%{http_code}" http://localhost:8000/health/ 2>/dev/null || echo "000")
if [ "$STATUS" = "200" ]; then
echo "Healthy after ~$((i * 2))s"
HEALTHY=1
break
fi
sleep 2
done
if [ "$HEALTHY" != "1" ]; then
echo "::error::Server never became healthy within 120s"
cat server.log || true
exit 1
fi
- name: OWASP ZAP Authenticated Full Scan
uses: zaproxy/action-full-scan@3c58388149901b9a03b7718852c5ba889646c27c # v0.13.0
with:
target: "http://localhost:8000"
allow_issue_writing: false
fail_action: true
cmd_options: "-a -n /zap/wrk/.zap/prei-auth-context-runtime.xml -U zap-ci-scan-only -I"
- name: Server logs
if: always()
run: cat server.log || true
# ── Coverage combine (gate: all test jobs must pass) ──────────────────────
coverage:
name: "📊 Coverage"
needs: [tests-unit, tests-integration, tests-e2e]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: ./.github/actions/python-setup
- name: Install coverage
run: pip install coverage
- name: Download unit coverage
uses: actions/download-artifact@v8
with:
name: coverage-unit
- name: Download integration coverage
uses: actions/download-artifact@v8
with:
name: coverage-integration
- name: Download e2e coverage
uses: actions/download-artifact@v8
with:
name: coverage-e2e
- name: Combine and report
run: |
coverage combine
coverage report --fail-under=70
# ── Summary gate ──────────────────────────────────────────────────────────
pr-gates-pass:
name: "All Gates Passed"
needs: [pr-title, lint, secrets, django-checks, tests-unit, tests-integration, tests-e2e, typecheck, acceptance-check, finance-math, coverage, zap-authenticated-scan]
runs-on: ubuntu-latest
if: always()
steps:
- name: Verify all gates
run: |
FAILED=""
if [ "${{ needs.pr-title.result }}" != "success" ]; then FAILED="$FAILED pr-title"; fi
if [ "${{ needs.lint.result }}" != "success" ]; then FAILED="$FAILED lint"; fi
if [ "${{ needs.secrets.result }}" != "success" ]; then FAILED="$FAILED secrets"; fi
if [ "${{ needs.django-checks.result }}" != "success" ]; then FAILED="$FAILED django-checks"; fi
if [ "${{ needs.tests-unit.result }}" != "success" ]; then FAILED="$FAILED tests-unit"; fi
if [ "${{ needs.tests-integration.result }}" != "success" ]; then FAILED="$FAILED tests-integration"; fi
if [ "${{ needs.tests-e2e.result }}" != "success" ]; then FAILED="$FAILED tests-e2e"; fi
if [ "${{ needs.typecheck.result }}" != "success" ]; then FAILED="$FAILED typecheck"; fi
if [ "${{ needs.acceptance-check.result }}" != "success" ]; then FAILED="$FAILED acceptance"; fi
if [ "${{ needs.finance-math.result }}" != "success" ]; then FAILED="$FAILED finance-math"; fi
if [ "${{ needs.coverage.result }}" != "success" ]; then FAILED="$FAILED coverage"; fi
if [ "${{ needs.zap-authenticated-scan.result }}" != "success" ]; then FAILED="$FAILED zap-authenticated-scan"; fi
if [ -n "$FAILED" ]; then
echo "❌ Failed gates:${FAILED}"
exit 1
fi
echo "✅ All gates passed"