Skip to content

fix(manual-entry): restore single-client lock by dropping force_selectable #431

fix(manual-entry): restore single-client lock by dropping force_selectable

fix(manual-entry): restore single-client lock by dropping force_selectable #431

name: Comprehensive CI Pipeline
# This workflow runs comprehensive tests on pull requests
#
# Test Strategy:
# - Smoke tests (fast, critical) run first
# - Unit, integration, security, and code quality tests run in parallel
# - Full test suite with PostgreSQL runs for PRs to main/master and RC branches
# - Docker build test ensures the image builds correctly
# - Test summary posted as PR comment
#
# All tests must pass before a PR can be merged
#
# Workflow triggers:
# - PRs to RC branches (from develop) - validates code before RC build
# - PRs to main/master (from RC) - validates code before release
# - workflow_dispatch - manual run from GitHub Actions (branch selectable in UI)
on:
pull_request:
branches: [ main, master, 'rc', 'rc/**' ]
workflow_dispatch:
env:
PYTHON_VERSION: '3.11'
POSTGRES_VERSION: '16'
jobs:
# ============================================================================
# Smoke Tests - Fast, critical tests that run first
# ============================================================================
smoke-tests:
name: Smoke Tests (Quick)
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: ${{ env.PYTHON_VERSION }}
cache: 'pip'
- name: Install dependencies
run: |
pip install -r requirements.txt
pip install -r requirements-test.txt
pip install -e .
- name: Run smoke tests
env:
PYTHONPATH: ${{ github.workspace }}
run: |
pytest -m smoke -v --tb=short --no-cov -n auto
- name: Upload smoke test results
if: always()
uses: actions/upload-artifact@v4
with:
name: smoke-test-results
path: |
.pytest_cache/
test-results/
# ============================================================================
# Unit Tests - Fast, isolated tests
# ============================================================================
unit-tests:
name: Unit Tests
runs-on: ubuntu-latest
needs: smoke-tests
timeout-minutes: 10
strategy:
fail-fast: false
matrix:
test-group: [models, routes, api, utils]
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: ${{ env.PYTHON_VERSION }}
cache: 'pip'
- name: Install dependencies
run: |
pip install -r requirements.txt
pip install -r requirements-test.txt
pip install -e .
- name: Run unit tests - ${{ matrix.test-group }}
env:
PYTHONPATH: ${{ github.workspace }}
FLASK_ENV: testing
SECRET_KEY: test-secret-key-for-ci
run: |
if [ "${{ matrix.test-group }}" == "api" ]; then
pytest -m "api and integration" -v -n auto --cov=app --cov-report=xml --cov-report=html --cov-report=term-missing
elif [ "${{ matrix.test-group }}" == "routes" ]; then
pytest -m "unit and routes" -v -n 0 --cov=app --cov-report=xml --cov-report=html --cov-report=term-missing
else
pytest -m "unit and ${{ matrix.test-group }}" -v -n auto --cov=app --cov-report=xml --cov-report=html --cov-report=term-missing
fi
- name: Upload coverage to Codecov
uses: codecov/codecov-action@b9fd7d16f6d7d1b5d2bec1a2887e65ceed900238 # v4
with:
files: ./coverage.xml
flags: unit-${{ matrix.test-group }}
name: unit-${{ matrix.test-group }}
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: unit-test-results-${{ matrix.test-group }}
path: |
htmlcov/
coverage.xml
# ============================================================================
# Integration Tests - Medium speed, component interaction tests
# ============================================================================
integration-tests:
name: Integration Tests
runs-on: ubuntu-latest
needs: smoke-tests
timeout-minutes: 15
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_PASSWORD: test_password
POSTGRES_USER: test_user
POSTGRES_DB: test_db
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: ${{ env.PYTHON_VERSION }}
cache: 'pip'
- name: Install dependencies
run: |
pip install -r requirements.txt
pip install -r requirements-test.txt
pip install -e .
- name: Run integration tests
env:
DATABASE_URL: postgresql://test_user:test_password@localhost:5432/test_db
FLASK_APP: app.py
FLASK_ENV: testing
PYTHONPATH: ${{ github.workspace }}
INSTALLATION_CONFIG_DIR: ${{ github.workspace }}/.test_installation_config
run: |
pytest -m integration -v -n auto --cov=app --cov-report=xml --cov-report=html --cov-report=term-missing
- name: Upload coverage to Codecov
uses: codecov/codecov-action@b9fd7d16f6d7d1b5d2bec1a2887e65ceed900238 # v4
with:
files: ./coverage.xml
flags: integration
name: integration-tests
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: integration-test-results
path: |
htmlcov/
coverage.xml
# ============================================================================
# Security Tests
# ============================================================================
security-tests:
name: Security Tests
runs-on: ubuntu-latest
needs: smoke-tests
timeout-minutes: 10
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: ${{ env.PYTHON_VERSION }}
cache: 'pip'
- name: Install dependencies
run: |
pip install -r requirements.txt
pip install -r requirements-test.txt
pip install -e .
- name: Run security tests
env:
PYTHONPATH: ${{ github.workspace }}
run: bash scripts/ci/security-pytest.sh
- name: Run Safety dependency check
run: |
mkdir -p "${{ github.workspace }}/.test_installation_config"
REPORT="${{ github.workspace }}/.test_installation_config/safety-report.json"
EC=0
python -m safety check --file requirements.txt --save-json "${REPORT}" >/dev/null || EC=$?
if [ -f "${REPORT}" ]; then
python "${{ github.workspace }}/scripts/ci/sanitize_safety_report.py" "${REPORT}" "${{ github.workspace }}"
fi
exit "${EC}"
- name: Upload security reports
if: always()
uses: actions/upload-artifact@v4
with:
name: security-reports
path: |
.test_installation_config/safety-report.json
# ============================================================================
# Code Quality
# ============================================================================
code-quality:
name: Code Quality Checks
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: ${{ env.PYTHON_VERSION }}
cache: 'pip'
- name: Install dependencies
run: |
pip install -r requirements.txt
pip install -r requirements-test.txt
pip install -e .
- name: Run flake8
run: |
flake8 app/ --count --select=E9,F63,F7,F82 --show-source --statistics
flake8 app/ --count --max-complexity=10 --max-line-length=120 --statistics
- name: Run black (format check)
run: black --check app/
- name: Run isort (import check)
run: isort --check-only app/
- name: Run mypy
run: mypy app/ || true
# Ratchets: these may improve but must never regress.
- name: Check silent exception handlers
run: python scripts/check_silent_excepts.py
- name: Check translation coverage
run: python scripts/check_translation_coverage.py
# ============================================================================
# Frontend Assets - build pipeline and self-hosting guarantees
# ============================================================================
frontend-assets:
name: Frontend Assets & Offline Guarantee
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: '18'
- name: Install frontend dependencies
run: npm install
# Runs Tailwind, vendors third-party libraries into app/static/vendor,
# and produces the content-hashed JS bundles + manifest.json.
- name: Build assets
run: npm run build:docker
- name: Verify build output
run: |
test -f app/static/dist/output.css
test -f app/static/dist/manifest.json
test -d app/static/vendor/fontawesome
echo "Bundles built:"
python -c "import json;print('\n'.join(f' {k} -> {v}' for k,v in json.load(open('app/static/dist/manifest.json')).items()))"
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: ${{ env.PYTHON_VERSION }}
cache: 'pip'
- name: Install Python dependencies
run: |
pip install -r requirements.txt
pip install -r requirements-test.txt
pip install -e .
# Fails if any template, stylesheet or script reaches out to a CDN, if Font
# Awesome is double-loaded, if the CSP allowlists a third party, or if an
# inline <script> is missing its nonce.
- name: Enforce no external assets
run: pytest tests/test_no_external_assets.py -v
# ============================================================================
# End-to-end: CSP, offline rendering, accessibility, page-weight budget
# ============================================================================
e2e:
name: E2E, CSP & Accessibility
runs-on: ubuntu-latest
needs: [docker-build]
timeout-minutes: 25
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: timetracker
POSTGRES_PASSWORD: timetracker
POSTGRES_DB: timetracker
ports: ['5432:5432']
options: >-
--health-cmd pg_isready --health-interval 10s
--health-timeout 5s --health-retries 5
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: '18'
- name: Install dependencies
run: npm install
- name: Install Playwright browser
run: npx playwright install --with-deps chromium
# Build and run the real image so the assets under test are the ones that ship.
- name: Build image
run: docker build -t timetracker-e2e:ci .
- name: Start application
env:
E2E_ADMIN_PASSWORD: ${{ secrets.E2E_ADMIN_PASSWORD || 'e2e-local-password-123' }}
run: |
docker run -d --name timetracker-e2e --network host \
-e SECRET_KEY=ci-e2e-secret-key-not-for-production \
-e DATABASE_URL=postgresql://timetracker:timetracker@localhost:5432/timetracker \
-e ADMIN_USERNAME=e2eadmin \
-e ADMIN_PASSWORD="$E2E_ADMIN_PASSWORD" \
-e TZ=UTC \
timetracker-e2e:ci
echo "Waiting for the app to become healthy..."
for i in $(seq 1 60); do
if curl -fsS http://localhost:8080/login >/dev/null 2>&1; then
echo "Ready after ${i}s"; exit 0
fi
sleep 1
done
echo "App did not start; container logs:" >&2
docker logs timetracker-e2e >&2
exit 1
- name: Run Playwright suite
env:
E2E_BASE_URL: http://localhost:8080
E2E_USERNAME: e2eadmin
E2E_PASSWORD: ${{ secrets.E2E_ADMIN_PASSWORD || 'e2e-local-password-123' }}
run: npx playwright test
- name: Container logs on failure
if: failure()
run: docker logs timetracker-e2e
- name: Upload Playwright report
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: playwright-report/
retention-days: 7
# ============================================================================
# Docker Build Test
# ============================================================================
docker-build:
name: Docker Build Test
runs-on: ubuntu-latest
needs: [unit-tests, integration-tests]
timeout-minutes: 20
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
- name: Build Docker image
run: |
docker build -t timetracker-test:pr-${{ github.event.pull_request.number || 'dev' }} .
- name: Test Docker container startup
run: |
# Start container
CONTAINER_ID=$(docker run -d --name test-container \
-p 8080:8080 \
-e DATABASE_URL="sqlite:////app/test.db" \
-e SECRET_KEY="test-secret-key-for-ci-only-$(openssl rand -hex 32)" \
-e FLASK_ENV="development" \
timetracker-test:pr-${{ github.event.pull_request.number || 'dev' }})
echo "🐳 Started container: $CONTAINER_ID"
# Wait for container to be ready (increased timeout for migrations)
HEALTH_CHECK_PASSED=false
for i in {1..60}; do
# Check if container is still running
if ! docker ps -q --filter "name=test-container" | grep -q .; then
echo "❌ Container exited unexpectedly!"
echo ""
echo "📋 Container logs:"
docker logs test-container
echo ""
echo "🔍 Container status:"
docker ps -a --filter "name=test-container"
exit 1
fi
# Try health check
if curl -f http://localhost:8080/_health >/dev/null 2>&1; then
echo "✅ Container health check passed (attempt $i/60)"
HEALTH_CHECK_PASSED=true
break
fi
# Show progress
if [ $((i % 10)) -eq 0 ]; then
echo "⏳ Still waiting for container... ($i/60)"
echo "📊 Last 10 log lines:"
docker logs --tail 10 test-container
else
echo "⏳ Waiting for container... ($i/60)"
fi
sleep 2
done
# Show full logs for debugging
echo ""
echo "📋 Full container logs:"
docker logs test-container
echo ""
# Check if health check passed
if [ "$HEALTH_CHECK_PASSED" = false ]; then
echo "❌ Health check never passed after 120 seconds"
echo ""
echo "🔍 Container inspect:"
docker inspect test-container
echo ""
echo "🔍 Container status:"
docker ps -a --filter "name=test-container"
exit 1
fi
# Final health check with detailed output
echo "🔍 Final health check:"
curl -v http://localhost:8080/_health || {
echo "❌ Final health check failed"
echo "📋 Latest logs:"
docker logs --tail 50 test-container
exit 1
}
echo "✅ Docker container test completed successfully"
# Cleanup
docker stop test-container
docker rm test-container
# ============================================================================
# Full Test Suite (runs on all PRs to main/master/rc)
# ============================================================================
full-test-suite:
name: Full Test Suite with PostgreSQL
runs-on: ubuntu-latest
needs: [smoke-tests, unit-tests, integration-tests]
if: github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && (github.base_ref == 'main' || github.base_ref == 'master' || startsWith(github.base_ref, 'rc')))
timeout-minutes: 30
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_PASSWORD: test_password
POSTGRES_USER: test_user
POSTGRES_DB: test_db
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: ${{ env.PYTHON_VERSION }}
cache: 'pip'
- name: Install dependencies
run: |
pip install -r requirements.txt
pip install -r requirements-test.txt
pip install -e .
- name: Validate database migrations
env:
DATABASE_URL: postgresql://test_user:test_password@localhost:5432/test_db
FLASK_APP: app.py
FLASK_ENV: testing
run: |
echo "🔍 Validating database migrations..."
# Check if there are migration-related changes
if git diff --name-only origin/${{ github.base_ref }}...HEAD | grep -E "(app/models/|migrations/)" > /dev/null; then
echo "📋 Migration-related changes detected"
# Initialize fresh database
flask db upgrade
# Test migration rollback
CURRENT_MIGRATION=$(flask db current)
echo "Current migration: $CURRENT_MIGRATION"
if [ -n "$CURRENT_MIGRATION" ] && [ "$CURRENT_MIGRATION" != "None" ]; then
echo "Testing migration operations..."
flask db upgrade head
echo "✅ Migration validation passed"
fi
# Test with sample data
python -c "
from app import create_app, db
from app.models.user import User
from app.models.project import Project
from app.models.client import Client
app = create_app()
with app.app_context():
user = User(username='test_user', role='user')
db.session.add(user)
db.session.commit()
client = Client(name='Test Client', description='Test client')
db.session.add(client)
db.session.commit()
project = Project(name='Test Project', client_id=client.id, description='Test project')
db.session.add(project)
db.session.commit()
print('✅ Sample data created and validated successfully')
"
else
echo "ℹ️ No migration-related changes detected"
fi
- name: Run full test suite
env:
DATABASE_URL: postgresql://test_user:test_password@localhost:5432/test_db
FLASK_APP: app.py
FLASK_ENV: testing
PYTHONPATH: ${{ github.workspace }}
INSTALLATION_CONFIG_DIR: ${{ github.workspace }}/.test_installation_config
run: |
pytest -v -n auto --cov=app --cov-report=xml --cov-report=html --cov-report=term-missing \
--cov-fail-under=35 --junitxml=junit.xml --maxfail=5
- name: Upload full coverage
uses: codecov/codecov-action@b9fd7d16f6d7d1b5d2bec1a2887e65ceed900238 # v4
with:
files: ./coverage.xml
flags: full-suite
name: full-test-suite
- name: Upload full test results
if: always()
uses: actions/upload-artifact@v4
with:
name: full-test-results
path: |
htmlcov/
coverage.xml
junit.xml
- name: Publish full test results
uses: EnricoMi/publish-unit-test-result-action@v2
if: always()
with:
files: junit.xml
check_name: Full Test Suite Results
# ============================================================================
# Test Summary and PR Comment
# ============================================================================
test-summary:
name: Test Summary
runs-on: ubuntu-latest
needs: [smoke-tests, unit-tests, integration-tests, security-tests, code-quality, frontend-assets, e2e, docker-build, full-test-suite]
if: always() && github.event_name == 'pull_request'
permissions:
contents: read
pull-requests: write
issues: write
steps:
- name: Generate test summary
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
with:
script: |
const jobs = [
{ name: 'Smoke Tests', result: '${{ needs.smoke-tests.result }}' },
{ name: 'Unit Tests', result: '${{ needs.unit-tests.result }}' },
{ name: 'Integration Tests', result: '${{ needs.integration-tests.result }}' },
{ name: 'Security Tests', result: '${{ needs.security-tests.result }}' },
{ name: 'Code Quality', result: '${{ needs.code-quality.result }}' },
{ name: 'Frontend Assets', result: '${{ needs.frontend-assets.result }}' },
{ name: 'E2E, CSP & Accessibility', result: '${{ needs.e2e.result }}' },
{ name: 'Docker Build', result: '${{ needs.docker-build.result }}' },
{ name: 'Full Test Suite', result: '${{ needs.full-test-suite.result }}' }
];
const passed = jobs.filter(j => j.result === 'success').length;
const failed = jobs.filter(j => j.result === 'failure').length;
const total = jobs.length;
let emoji = failed === 0 ? '✅' : '❌';
let status = failed === 0 ? 'All tests passed!' : `${failed} test suite(s) failed`;
let commentBody = `## ${emoji} CI Test Results\n\n`;
commentBody += `**Overall Status:** ${status}\n\n`;
commentBody += `**Test Results:** ${passed}/${total} passed\n\n`;
commentBody += `### Test Suites:\n\n`;
for (const job of jobs) {
const icon = job.result === 'success' ? '✅' :
job.result === 'failure' ? '❌' :
job.result === 'skipped' ? '⏭️' : '⏸️';
commentBody += `- ${icon} ${job.name}: **${job.result}**\n`;
}
commentBody += `\n---\n`;
commentBody += `*Commit: ${context.sha.substring(0, 7)}*\n`;
commentBody += `*Workflow: [${context.runId}](${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId})*`;
// Find existing comment
const { data: comments } = await github.rest.issues.listComments({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
});
const botComment = comments.find(comment =>
comment.user.type === 'Bot' &&
comment.body.includes('CI Test Results')
);
if (botComment) {
await github.rest.issues.updateComment({
comment_id: botComment.id,
owner: context.repo.owner,
repo: context.repo.repo,
body: commentBody,
});
} else {
await github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: commentBody,
});
}