chore(main): release 0.4.6 #837
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: 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" |