ci: enterprise-grade unified CI pipeline

- Consolidate 16 workflows → 7 workflows
- Single CI workflow per PR (Stage 1→2→3→4)
- Smart path filtering (skip tests for docs-only changes)
- Auto-labeling (type, area, size, status)
- Production-ready with proper documentation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
This commit is contained in:
Alex Madera
2026-01-02 16:36:47 +01:00
co-authored by Claude Opus 4.5
parent 90f5b59c35
commit 052e6a0970
2 changed files with 502 additions and 633 deletions
+502 -202
View File
@@ -1,50 +1,276 @@
# ╔═══════════════════════════════════════════════════════════════════════════════╗
# ║ ║
# ║ AUTO CLAUDE - CI PIPELINE ║
# ║ ║
# ║ A unified, enterprise-grade CI workflow for pull request validation. ║
# ║ ║
# ║ WORKFLOW STAGES: ║
# ║ ┌─────────────────────────────────────────────────────────────────────────┐ ║
# ║ │ Stage 1: PR Setup - Label PR and set "Checking" status │ ║
# ║ │ Stage 2: Change Detection - Determine which tests to run │ ║
# ║ │ Stage 3: Quality Gates - Run tests, linting, and security scans │ ║
# ║ │ Stage 4: Status Update - Update PR with final status │ ║
# ║ └─────────────────────────────────────────────────────────────────────────┘ ║
# ║ ║
# ║ SMART PATH FILTERING: ║
# ║ - Backend changes (apps/backend/**, tests/**) → Python tests + lint ║
# ║ - Frontend changes (apps/frontend/**) → Frontend tests + build ║
# ║ - No code changes (docs, CI configs only) → Skip tests, mark ready ║
# ║ ║
# ║ LABELS APPLIED: ║
# ║ - Status: 🔄 Checking → ✅ Ready for Review / ❌ Checks Failed ║
# ║ - Type: feature, bug, docs, refactor, ci, chore (from PR title) ║
# ║ - Area: area/frontend, area/backend, area/fullstack, area/ci ║
# ║ - Size: size/XS, size/S, size/M, size/L, size/XL ║
# ║ ║
# ║ MAINTAINERS: See CONTRIBUTING.md for workflow modification guidelines. ║
# ║ ║
# ╚═══════════════════════════════════════════════════════════════════════════════╝
name: CI
# PR-only trigger prevents double runs (no push trigger)
on:
pull_request:
branches: [main, develop]
paths-ignore:
- '**.md'
- 'docs/**'
- '.github/ISSUE_TEMPLATE/**'
- '.github/FUNDING.yml'
- '.github/PULL_REQUEST_TEMPLATE.md'
- 'LICENSE'
- '.gitignore'
types: [opened, synchronize, reopened]
# ─────────────────────────────────────────────────────────────────────────────────
# CONCURRENCY: Cancel redundant runs when new commits are pushed to the same PR
# ─────────────────────────────────────────────────────────────────────────────────
concurrency:
group: ci-${{ github.head_ref || github.ref }}
cancel-in-progress: true
# ─────────────────────────────────────────────────────────────────────────────────
# PERMISSIONS: Minimal permissions required for this workflow
# ─────────────────────────────────────────────────────────────────────────────────
permissions:
contents: read
actions: read
security-events: write
contents: read # Read repository contents
actions: read # Read workflow runs
security-events: write # Upload CodeQL results
pull-requests: write # Update PR labels
checks: read # Read check run status
# ═══════════════════════════════════════════════════════════════════════════════════
# JOBS
# ═══════════════════════════════════════════════════════════════════════════════════
jobs:
# ═══════════════════════════════════════════════════════════════════════════
# CHANGE DETECTION - Skip jobs based on what files changed
# ═══════════════════════════════════════════════════════════════════════════
changes:
name: Detect Changes
# ┌─────────────────────────────────────────────────────────────────────────────┐
# │ STAGE 1: PR SETUP │
# │ │
# │ Purpose: Initialize PR with labels and "Checking" status │
# │ Runs: First, before any other job │
# │ Labels: Status (Checking), Type, Area, Size │
# └─────────────────────────────────────────────────────────────────────────────┘
stage-1-setup:
name: "Stage 1: PR Setup"
runs-on: ubuntu-latest
# Skip for fork PRs (they cannot write labels)
if: github.event.pull_request.head.repo.full_name == github.repository
timeout-minutes: 5
steps:
- name: "1.1 Apply Labels and Set Checking Status"
uses: actions/github-script@v7
with:
retries: 3
retry-exempt-status-codes: 400,401,403,404,422
script: |
const { owner, repo } = context.repo;
const pr = context.payload.pull_request;
const prNumber = pr.number;
const title = pr.title;
console.log(`\n${'═'.repeat(60)}`);
console.log(` PR #${prNumber}: ${title}`);
console.log(`${'═'.repeat(60)}\n`);
const labelsToAdd = new Set();
const labelsToRemove = new Set();
// ─────────────────────────────────────────────────────────────
// STATUS LABEL: Set to "Checking" while CI runs
// ─────────────────────────────────────────────────────────────
const statusLabels = ['🔄 Checking', '✅ Ready for Review', '❌ Checks Failed'];
statusLabels.forEach(l => labelsToRemove.add(l));
labelsToAdd.add('🔄 Checking');
console.log('Status: 🔄 Checking');
// ─────────────────────────────────────────────────────────────
// TYPE LABEL: Extracted from Conventional Commit prefix
// Format: type(scope)!: description
// Examples: feat:, fix(ui):, docs!:, refactor(api):
// ─────────────────────────────────────────────────────────────
const typeMap = {
'feat': 'feature', // New feature
'fix': 'bug', // Bug fix
'docs': 'documentation',// Documentation only
'refactor': 'refactor', // Code refactoring
'test': 'test', // Adding/updating tests
'ci': 'ci', // CI/CD changes
'chore': 'chore', // Maintenance tasks
'perf': 'performance', // Performance improvements
'style': 'style', // Code style changes
'build': 'build' // Build system changes
};
const typeMatch = title.match(/^(\w+)(\(.+?\))?(!)?:/);
if (typeMatch) {
const type = typeMatch[1].toLowerCase();
const isBreaking = typeMatch[3] === '!';
if (typeMap[type]) {
labelsToAdd.add(typeMap[type]);
console.log(`Type: ${typeMap[type]}`);
}
if (isBreaking) {
labelsToAdd.add('breaking-change');
console.log('⚠️ Breaking change detected');
}
} else {
console.log('Type: (no conventional commit prefix detected)');
}
// ─────────────────────────────────────────────────────────────
// AREA LABEL: Determined by which files were changed
// ─────────────────────────────────────────────────────────────
let files = [];
try {
const { data } = await github.rest.pulls.listFiles({
owner, repo, pull_number: prNumber, per_page: 100
});
files = data;
} catch (e) {
console.log(`Warning: Could not fetch changed files: ${e.message}`);
}
const areas = { frontend: false, backend: false, ci: false };
for (const file of files) {
const path = file.filename;
if (path.startsWith('apps/frontend/')) areas.frontend = true;
if (path.startsWith('apps/backend/') || path.startsWith('tests/')) areas.backend = true;
if (path.startsWith('.github/')) areas.ci = true;
}
// Area labels are mutually exclusive
const areaLabels = ['area/frontend', 'area/backend', 'area/fullstack', 'area/ci'];
let areaLabel = null;
if (areas.frontend && areas.backend) {
areaLabel = 'area/fullstack';
} else if (areas.frontend) {
areaLabel = 'area/frontend';
} else if (areas.backend) {
areaLabel = 'area/backend';
} else if (areas.ci) {
areaLabel = 'area/ci';
}
if (areaLabel) {
labelsToAdd.add(areaLabel);
areaLabels.filter(l => l !== areaLabel).forEach(l => labelsToRemove.add(l));
console.log(`Area: ${areaLabel.replace('area/', '')}`);
}
// ─────────────────────────────────────────────────────────────
// SIZE LABEL: Based on total lines changed
// XS: <10, S: <100, M: <500, L: <1000, XL: >=1000
// ─────────────────────────────────────────────────────────────
const additions = pr.additions || 0;
const deletions = pr.deletions || 0;
const totalLines = additions + deletions;
const sizeLabels = ['size/XS', 'size/S', 'size/M', 'size/L', 'size/XL'];
const sizeLabel = totalLines < 10 ? 'size/XS' :
totalLines < 100 ? 'size/S' :
totalLines < 500 ? 'size/M' :
totalLines < 1000 ? 'size/L' : 'size/XL';
labelsToAdd.add(sizeLabel);
sizeLabels.filter(l => l !== sizeLabel).forEach(l => labelsToRemove.add(l));
console.log(`Size: ${sizeLabel.replace('size/', '')} (+${additions}/-${deletions} = ${totalLines} lines)`);
// ─────────────────────────────────────────────────────────────
// APPLY LABELS
// ─────────────────────────────────────────────────────────────
console.log(`\n${'─'.repeat(60)}`);
console.log('Applying labels...');
// Remove old labels first
const removeArray = [...labelsToRemove].filter(l => !labelsToAdd.has(l));
for (const label of removeArray) {
try {
await github.rest.issues.removeLabel({
owner, repo, issue_number: prNumber, name: label
});
} catch (e) {
// Ignore 404 (label not present)
}
}
// Add new labels
const addArray = [...labelsToAdd];
if (addArray.length > 0) {
try {
await github.rest.issues.addLabels({
owner, repo, issue_number: prNumber, labels: addArray
});
console.log(`✓ Labels applied: ${addArray.join(', ')}`);
} catch (e) {
if (e.status === 404) {
core.warning('Some labels do not exist. Please create them in Settings > Labels.');
// Try adding labels one by one
for (const label of addArray) {
try {
await github.rest.issues.addLabels({
owner, repo, issue_number: prNumber, labels: [label]
});
} catch (e2) {
console.log(` ⚠ Label '${label}' does not exist`);
}
}
}
}
}
console.log(`${'─'.repeat(60)}\n`);
# ┌─────────────────────────────────────────────────────────────────────────────┐
# │ STAGE 2: CHANGE DETECTION │
# │ │
# │ Purpose: Analyze changed files to determine which tests to run │
# │ Runs: After Stage 1 completes │
# │ Outputs: backend, frontend, any_code, skip_tests │
# └─────────────────────────────────────────────────────────────────────────────┘
stage-2-changes:
name: "Stage 2: Detect Changes"
runs-on: ubuntu-latest
needs: stage-1-setup
# Always run even if stage-1 was skipped (fork PRs)
if: always()
timeout-minutes: 5
outputs:
backend: ${{ steps.filter.outputs.backend }}
frontend: ${{ steps.filter.outputs.frontend }}
any_code: ${{ steps.filter.outputs.any_code }}
steps:
- uses: actions/checkout@v4
skip_tests: ${{ steps.evaluate.outputs.skip_tests }}
- uses: dorny/paths-filter@v3
steps:
- name: "2.1 Checkout Repository"
uses: actions/checkout@v4
- name: "2.2 Analyze Changed Files"
id: filter
uses: dorny/paths-filter@v3
with:
filters: |
backend:
- 'apps/backend/**'
- 'tests/**'
- 'requirements*.txt'
- 'apps/backend/requirements.txt'
frontend:
- 'apps/frontend/**'
- 'package*.json'
@@ -54,39 +280,78 @@ jobs:
- 'package*.json'
- 'requirements*.txt'
# ═══════════════════════════════════════════════════════════════════════════
# PYTHON TESTS
# ═══════════════════════════════════════════════════════════════════════════
test-python:
name: test-python (${{ matrix.python-version }})
needs: changes
if: needs.changes.outputs.backend == 'true'
- name: "2.3 Evaluate Test Requirements"
id: evaluate
run: |
echo ""
echo "═══════════════════════════════════════════════════════════"
echo " CHANGE DETECTION RESULTS"
echo "═══════════════════════════════════════════════════════════"
echo ""
echo " Backend changes: ${{ steps.filter.outputs.backend }}"
echo " Frontend changes: ${{ steps.filter.outputs.frontend }}"
echo " Any code changes: ${{ steps.filter.outputs.any_code }}"
echo ""
if [ "${{ steps.filter.outputs.any_code }}" = "false" ]; then
echo "skip_tests=true" >> $GITHUB_OUTPUT
echo " → No code changes detected"
echo " → Tests will be SKIPPED (docs/config only changes)"
else
echo "skip_tests=false" >> $GITHUB_OUTPUT
echo " → Code changes detected"
echo " → Tests will be EXECUTED"
fi
echo ""
echo "═══════════════════════════════════════════════════════════"
# ┌─────────────────────────────────────────────────────────────────────────────┐
# │ STAGE 3: QUALITY GATES │
# │ │
# │ Purpose: Run tests, linting, and security scans based on detected changes │
# │ Runs: After Stage 2, jobs run in parallel where possible │
# │ Jobs: Python Tests, Frontend Tests, Python Lint, CodeQL, Bandit │
# └─────────────────────────────────────────────────────────────────────────────┘
# ─────────────────────────────────────────────────────────────────────────────
# PYTHON TESTS: Run pytest across Python version matrix
# Triggered: When backend files change
# ─────────────────────────────────────────────────────────────────────────────
stage-3-test-python:
name: "Stage 3: Python Tests (${{ matrix.python-version }})"
runs-on: ubuntu-latest
needs: stage-2-changes
if: needs.stage-2-changes.outputs.backend == 'true'
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
python-version: ['3.12', '3.13']
steps:
- name: Checkout
- name: "3.1 Checkout Repository"
uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
- name: "3.2 Setup Python ${{ matrix.python-version }}"
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install uv
- name: "3.3 Setup UV Package Manager"
uses: astral-sh/setup-uv@v4
with:
version: "latest"
- name: Install dependencies
- name: "3.4 Install Dependencies"
working-directory: apps/backend
run: |
uv venv
uv pip install -r requirements.txt
uv pip install -r ../../tests/requirements-test.txt
- name: Run tests
- name: "3.5 Run Test Suite"
working-directory: apps/backend
env:
PYTHONPATH: ${{ github.workspace }}/apps/backend
@@ -94,16 +359,20 @@ jobs:
source .venv/bin/activate
pytest ../../tests/ -v --tb=short -x
- name: Run tests with coverage
- name: "3.6 Run Tests with Coverage"
if: matrix.python-version == '3.12'
working-directory: apps/backend
env:
PYTHONPATH: ${{ github.workspace }}/apps/backend
run: |
source .venv/bin/activate
pytest ../../tests/ -v --cov=. --cov-report=xml --cov-report=term-missing --cov-fail-under=20
pytest ../../tests/ -v \
--cov=. \
--cov-report=xml \
--cov-report=term-missing \
--cov-fail-under=20
- name: Upload coverage reports
- name: "3.7 Upload Coverage to Codecov"
if: matrix.python-version == '3.12'
uses: codecov/codecov-action@v4
with:
@@ -112,152 +381,151 @@ jobs:
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
# ═══════════════════════════════════════════════════════════════════════════
# FRONTEND TESTS (Lint, TypeCheck, Test, Build)
# ═══════════════════════════════════════════════════════════════════════════
test-frontend:
name: test-frontend
needs: changes
if: needs.changes.outputs.frontend == 'true'
# ─────────────────────────────────────────────────────────────────────────────
# FRONTEND TESTS: Lint, typecheck, test, and build
# Triggered: When frontend files change
# ─────────────────────────────────────────────────────────────────────────────
stage-3-test-frontend:
name: "Stage 3: Frontend Tests"
runs-on: ubuntu-latest
needs: stage-2-changes
if: needs.stage-2-changes.outputs.frontend == 'true'
timeout-minutes: 15
steps:
- name: Checkout
- name: "3.1 Checkout Repository"
uses: actions/checkout@v4
- name: Setup Node.js
- name: "3.2 Setup Node.js"
uses: actions/setup-node@v4
with:
node-version: '24'
- name: Get npm cache directory
id: npm-cache
run: echo "dir=$(npm config get cache)" >> "$GITHUB_OUTPUT"
- uses: actions/cache@v4
- name: "3.3 Cache npm Dependencies"
uses: actions/cache@v4
with:
path: ${{ steps.npm-cache.outputs.dir }}
path: ~/.npm
key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
restore-keys: ${{ runner.os }}-npm-
- name: Install dependencies
- name: "3.4 Install Dependencies"
working-directory: apps/frontend
run: npm ci --ignore-scripts
- name: Lint
- name: "3.5 Run ESLint"
working-directory: apps/frontend
run: npm run lint
- name: Type check
- name: "3.6 Run TypeScript Type Check"
working-directory: apps/frontend
run: npm run typecheck
- name: Run tests
- name: "3.7 Run Unit Tests"
working-directory: apps/frontend
run: npm run test
- name: Build
- name: "3.8 Build Application"
working-directory: apps/frontend
run: npm run build
# ═══════════════════════════════════════════════════════════════════════════
# PYTHON LINTING (Ruff)
# ═══════════════════════════════════════════════════════════════════════════
lint-python:
name: lint-python
needs: changes
if: needs.changes.outputs.backend == 'true'
# ─────────────────────────────────────────────────────────────────────────────
# PYTHON LINT: Check code style and formatting with Ruff
# Triggered: When backend files change
# ─────────────────────────────────────────────────────────────────────────────
stage-3-lint-python:
name: "Stage 3: Python Lint"
runs-on: ubuntu-latest
needs: stage-2-changes
if: needs.stage-2-changes.outputs.backend == 'true'
timeout-minutes: 10
steps:
- name: Checkout
- name: "3.1 Checkout Repository"
uses: actions/checkout@v4
- name: Set up Python
- name: "3.2 Setup Python"
uses: actions/setup-python@v5
with:
python-version: '3.12'
# Pin ruff version to match .pre-commit-config.yaml (astral-sh/ruff-pre-commit rev)
- name: Install ruff
# Ruff version pinned to match .pre-commit-config.yaml
- name: "3.3 Install Ruff"
run: pip install ruff==0.14.10
- name: Run ruff check
- name: "3.4 Run Ruff Linter"
run: ruff check apps/backend/ --output-format=github
- name: Run ruff format check
- name: "3.5 Check Code Formatting"
run: ruff format apps/backend/ --check --diff
# ═══════════════════════════════════════════════════════════════════════════
# SECURITY - CodeQL Analysis
# ═══════════════════════════════════════════════════════════════════════════
codeql:
name: codeql (${{ matrix.language }})
needs: changes
if: needs.changes.outputs.any_code == 'true'
# ─────────────────────────────────────────────────────────────────────────────
# CODEQL: Static analysis for security vulnerabilities
# Triggered: When any code files change
# ─────────────────────────────────────────────────────────────────────────────
stage-3-codeql:
name: "Stage 3: CodeQL (${{ matrix.language }})"
runs-on: ubuntu-latest
needs: stage-2-changes
if: needs.stage-2-changes.outputs.any_code == 'true'
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
language: [python, javascript-typescript]
steps:
- name: Checkout
- name: "3.1 Checkout Repository"
uses: actions/checkout@v4
- name: Initialize CodeQL
- name: "3.2 Initialize CodeQL"
uses: github/codeql-action/init@v3
with:
languages: ${{ matrix.language }}
queries: +security-extended,security-and-quality
- name: Autobuild
- name: "3.3 Autobuild"
uses: github/codeql-action/autobuild@v3
- name: Perform CodeQL Analysis
- name: "3.4 Run CodeQL Analysis"
uses: github/codeql-action/analyze@v3
with:
category: "/language:${{ matrix.language }}"
# ═══════════════════════════════════════════════════════════════════════════
# SECURITY - Python Bandit
# ═══════════════════════════════════════════════════════════════════════════
python-security:
name: python-security
needs: changes
if: needs.changes.outputs.backend == 'true'
# ─────────────────────────────────────────────────────────────────────────────
# PYTHON SECURITY: Bandit security scanner for Python code
# Triggered: When backend files change
# ─────────────────────────────────────────────────────────────────────────────
stage-3-security-python:
name: "Stage 3: Python Security"
runs-on: ubuntu-latest
needs: stage-2-changes
if: needs.stage-2-changes.outputs.backend == 'true'
timeout-minutes: 10
steps:
- name: Checkout
- name: "3.1 Checkout Repository"
uses: actions/checkout@v4
- name: Set up Python
- name: "3.2 Setup Python"
uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install Bandit
- name: "3.3 Install Bandit"
run: pip install bandit
- name: Run Bandit security scan
id: bandit
- name: "3.4 Run Bandit Security Scan"
run: |
echo "::group::Running Bandit security scan"
# Run Bandit; exit code 1 means issues found (expected), other codes are errors
# Flags: -r=recursive, -ll=severity LOW+, -ii=confidence LOW+, -f=format, -o=output
bandit -r apps/backend/ -ll -ii -f json -o bandit-report.json || BANDIT_EXIT=$?
if [ "${BANDIT_EXIT:-0}" -gt 1 ]; then
echo "::error::Bandit scan failed with exit code $BANDIT_EXIT"
exit 1
fi
echo "::endgroup::"
# Run Bandit and save results (exit code 1 = issues found, not an error)
bandit -r apps/backend/ -ll -ii -f json -o bandit-report.json || true
- name: Analyze Bandit results
- name: "3.5 Analyze Security Results"
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
// Check if report exists
if (!fs.existsSync('bandit-report.json')) {
core.setFailed('Bandit report not found - scan may have failed');
return;
@@ -271,128 +539,160 @@ jobs:
const medium = results.filter(r => r.issue_severity === 'MEDIUM');
const low = results.filter(r => r.issue_severity === 'LOW');
console.log(`::group::Bandit Security Scan Results`);
console.log(`Found ${results.length} issues:`);
console.log(` HIGH: ${high.length}`);
console.log('\n' + '═'.repeat(60));
console.log(' BANDIT SECURITY SCAN RESULTS');
console.log('═'.repeat(60));
console.log(`\n HIGH: ${high.length}`);
console.log(` MEDIUM: ${medium.length}`);
console.log(` LOW: ${low.length}`);
console.log('');
console.log(` LOW: ${low.length}\n`);
// Print high severity issues
if (high.length > 0) {
console.log('High Severity Issues:');
console.log('-'.repeat(60));
console.log('─'.repeat(60));
console.log(' HIGH SEVERITY ISSUES:');
console.log('─'.repeat(60));
for (const issue of high) {
console.log(` ${issue.filename}:${issue.line_number}`);
console.log(` ${issue.issue_text}`);
console.log(` Test: ${issue.test_id} (${issue.test_name})`);
console.log('');
console.log(`\n 📍 ${issue.filename}:${issue.line_number}`);
console.log(` ${issue.issue_text}`);
console.log(` Test: ${issue.test_id} (${issue.test_name})`);
}
}
console.log('::endgroup::');
// Build summary
let summary = `## Python Security Scan (Bandit)\n\n`;
summary += `| Severity | Count |\n`;
summary += `|----------|-------|\n`;
summary += `| High | ${high.length} |\n`;
summary += `| Medium | ${medium.length} |\n`;
summary += `| Low | ${low.length} |\n\n`;
if (high.length > 0) {
summary += `### High Severity Issues\n\n`;
for (const issue of high) {
summary += `- **${issue.filename}:${issue.line_number}**\n`;
summary += ` - ${issue.issue_text}\n`;
summary += ` - Test: \`${issue.test_id}\` (${issue.test_name})\n\n`;
}
}
core.summary.addRaw(summary);
await core.summary.write();
// Fail if high severity issues found
if (high.length > 0) {
console.log('\n' + '═'.repeat(60));
core.setFailed(`Found ${high.length} high severity security issue(s)`);
} else {
console.log('No high severity security issues found');
console.log('No high severity security issues found');
console.log('═'.repeat(60) + '\n');
}
# ═══════════════════════════════════════════════════════════════════════════
# SECURITY SUMMARY - Aggregates all security check results
# ═══════════════════════════════════════════════════════════════════════════
security-summary:
name: security-summary
needs: [changes, codeql, python-security]
if: always() && needs.changes.outputs.any_code == 'true'
# ┌─────────────────────────────────────────────────────────────────────────────┐
# │ STAGE 4: STATUS UPDATE │
# │ │
# │ Purpose: Aggregate results from all quality gates and update PR status │
# │ Runs: After ALL Stage 3 jobs complete (success, failure, or skipped) │
# │ Updates: PR label to "Ready for Review" or "Checks Failed" │
# └─────────────────────────────────────────────────────────────────────────────┘
stage-4-status:
name: "Stage 4: Update PR Status"
runs-on: ubuntu-latest
needs:
- stage-2-changes
- stage-3-test-python
- stage-3-test-frontend
- stage-3-lint-python
- stage-3-codeql
- stage-3-security-python
# Always run to update status, even if previous jobs failed or were skipped
if: always() && github.event.pull_request.head.repo.full_name == github.repository
timeout-minutes: 5
steps:
- name: Check security results
- name: "4.1 Evaluate CI Results and Update PR"
uses: actions/github-script@v7
with:
script: |
const codeql = '${{ needs.codeql.result }}';
const bandit = '${{ needs.python-security.result }}';
const { owner, repo } = context.repo;
const prNumber = context.payload.pull_request.number;
console.log('Security Check Results:');
console.log(` CodeQL: ${codeql}`);
console.log(` Bandit: ${bandit}`);
// Only 'failure' is a real failure; 'skipped' is acceptable (e.g., path filters)
const acceptable = ['success', 'skipped'];
const codeqlOk = acceptable.includes(codeql);
const banditOk = acceptable.includes(bandit);
const allPassed = codeqlOk && banditOk;
if (allPassed) {
console.log('\nAll security checks passed');
core.summary.addRaw('## Security Checks Passed\n\nAll security scans completed successfully.');
} else {
console.log('\nSome security checks failed');
core.summary.addRaw('## Security Checks Failed\n\nOne or more security scans found issues.');
core.setFailed('Security checks failed');
}
await core.summary.write();
# ═══════════════════════════════════════════════════════════════════════════
# CI STATUS - Final job that indicates overall CI status for branch protection
# ═══════════════════════════════════════════════════════════════════════════
ci-status:
name: CI Status
needs: [changes, test-python, test-frontend, lint-python, codeql, python-security, security-summary]
if: always()
runs-on: ubuntu-latest
steps:
- name: Check CI results
uses: actions/github-script@v7
with:
script: |
// ─────────────────────────────────────────────────────────────
// COLLECT RESULTS FROM ALL QUALITY GATE JOBS
// ─────────────────────────────────────────────────────────────
const results = {
'test-python': '${{ needs.test-python.result }}',
'test-frontend': '${{ needs.test-frontend.result }}',
'lint-python': '${{ needs.lint-python.result }}',
'codeql': '${{ needs.codeql.result }}',
'python-security': '${{ needs.python-security.result }}',
'security-summary': '${{ needs.security-summary.result }}'
'Python Tests': '${{ needs.stage-3-test-python.result }}',
'Frontend Tests': '${{ needs.stage-3-test-frontend.result }}',
'Python Lint': '${{ needs.stage-3-lint-python.result }}',
'CodeQL': '${{ needs.stage-3-codeql.result }}',
'Python Security': '${{ needs.stage-3-security-python.result }}'
};
console.log('CI Results:');
for (const [job, result] of Object.entries(results)) {
console.log(` ${job}: ${result}`);
const skipTests = '${{ needs.stage-2-changes.outputs.skip_tests }}' === 'true';
console.log('\n' + '═'.repeat(60));
console.log(' CI PIPELINE RESULTS');
console.log('═'.repeat(60) + '\n');
if (skipTests) {
console.log(' ️ No code changes detected - tests were skipped\n');
}
// 'skipped' is acceptable (path filters), 'success' is good, anything else is bad
console.log(' Job Results:');
console.log(' ' + '─'.repeat(40));
for (const [job, result] of Object.entries(results)) {
const icon = result === 'success' ? '✓' :
result === 'skipped' ? '○' :
result === 'failure' ? '✗' : '?';
console.log(` ${icon} ${job}: ${result}`);
}
console.log(' ' + '─'.repeat(40) + '\n');
// ─────────────────────────────────────────────────────────────
// DETERMINE FINAL STATUS
// Success and skipped are acceptable; failure is not
// ─────────────────────────────────────────────────────────────
const acceptable = ['success', 'skipped'];
const failed = Object.entries(results)
.filter(([_, result]) => !acceptable.includes(result))
.map(([job, result]) => `${job}: ${result}`);
.map(([job, _]) => job);
if (failed.length > 0) {
console.log('\nFailed jobs:');
failed.forEach(f => console.log(` - ${f}`));
core.setFailed(`CI failed: ${failed.join(', ')}`);
} else {
console.log('\nAll CI checks passed (or were skipped due to path filters)');
const statusLabels = {
checking: '🔄 Checking',
passed: '✅ Ready for Review',
failed: '❌ Checks Failed'
};
// ─────────────────────────────────────────────────────────────
// UPDATE PR LABELS
// ─────────────────────────────────────────────────────────────
// Remove all status labels first
for (const label of Object.values(statusLabels)) {
try {
await github.rest.issues.removeLabel({
owner, repo, issue_number: prNumber, name: label
});
} catch (e) {
// Ignore 404 (label not present)
}
}
// Add appropriate status label
const newLabel = failed.length > 0 ? statusLabels.failed : statusLabels.passed;
try {
await github.rest.issues.addLabels({
owner, repo, issue_number: prNumber, labels: [newLabel]
});
} catch (e) {
if (e.status === 404) {
core.warning(`Label '${newLabel}' does not exist. Please create it in Settings > Labels.`);
}
}
// ─────────────────────────────────────────────────────────────
// GENERATE SUMMARY
// ─────────────────────────────────────────────────────────────
if (failed.length > 0) {
console.log(` ❌ RESULT: CI FAILED`);
console.log(` Failed jobs: ${failed.join(', ')}`);
console.log('\n' + '═'.repeat(60) + '\n');
core.summary.addRaw(`## ❌ CI Failed\n\n`);
core.summary.addRaw(`The following checks failed:\n`);
for (const job of failed) {
core.summary.addRaw(`- ${job}\n`);
}
core.setFailed(`CI failed: ${failed.join(', ')}`);
} else if (skipTests) {
console.log(` ✅ RESULT: READY FOR REVIEW`);
console.log(` No code changes - tests skipped`);
console.log('\n' + '═'.repeat(60) + '\n');
core.summary.addRaw(`## ✅ Ready for Review\n\n`);
core.summary.addRaw(`No code changes detected. Documentation or configuration changes only.\n`);
} else {
console.log(` ✅ RESULT: READY FOR REVIEW`);
console.log(` All quality gates passed`);
console.log('\n' + '═'.repeat(60) + '\n');
core.summary.addRaw(`## ✅ Ready for Review\n\n`);
core.summary.addRaw(`All CI checks passed successfully.\n`);
}
await core.summary.write();
-431
View File
@@ -1,431 +0,0 @@
name: PR Management
# Consolidated PR automation:
# - Sets initial "Checking" status label on PR open/sync
# - Auto-labels PRs by type (conventional commits), area (files changed), and size
# - Updates status to "Ready for Review" or "Checks Failed" after CI completes
on:
pull_request:
types: [opened, synchronize, reopened]
workflow_run:
workflows: [CI]
types: [completed]
concurrency:
group: pr-mgmt-${{ github.event.pull_request.number || github.event.workflow_run.pull_requests[0].number || github.ref }}
cancel-in-progress: true
permissions:
contents: read
pull-requests: write
checks: read
jobs:
# ═══════════════════════════════════════════════════════════════════════════
# INITIAL LABELING - Runs on PR open/sync
# Sets "Checking" status and auto-labels by type/area/size
# ═══════════════════════════════════════════════════════════════════════════
label-and-status:
name: Label & Set Status
runs-on: ubuntu-latest
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository
timeout-minutes: 5
steps:
- name: Auto-label and set checking status
uses: actions/github-script@v7
with:
retries: 3
retry-exempt-status-codes: 400,401,403,404,422
script: |
const { owner, repo } = context.repo;
const pr = context.payload.pull_request;
const prNumber = pr.number;
const title = pr.title;
console.log(`::group::PR #${prNumber} - Auto-labeling and setting status`);
console.log(`Title: ${title}`);
const labelsToAdd = new Set();
const labelsToRemove = new Set();
// ═══════════════════════════════════════════════════════════════
// STATUS LABELS - Set to "Checking"
// ═══════════════════════════════════════════════════════════════
const statusLabels = ['🔄 Checking', '✅ Ready for Review', '❌ Checks Failed'];
// Remove old status labels
for (const label of statusLabels) {
labelsToRemove.add(label);
}
labelsToAdd.add('🔄 Checking');
// ═══════════════════════════════════════════════════════════════
// TYPE LABELS (from PR title - Conventional Commits)
// ═══════════════════════════════════════════════════════════════
const typeMap = {
'feat': 'feature',
'fix': 'bug',
'docs': 'documentation',
'refactor': 'refactor',
'test': 'test',
'ci': 'ci',
'chore': 'chore',
'perf': 'performance',
'style': 'style',
'build': 'build'
};
const typeMatch = title.match(/^(\w+)(\(.+?\))?(!)?:/);
if (typeMatch) {
const type = typeMatch[1].toLowerCase();
const isBreaking = typeMatch[3] === '!';
if (typeMap[type]) {
labelsToAdd.add(typeMap[type]);
console.log(` Type: ${type} -> ${typeMap[type]}`);
}
if (isBreaking) {
labelsToAdd.add('breaking-change');
console.log(` Breaking change detected`);
}
} else {
console.log(` No conventional commit prefix found in title`);
}
// ═══════════════════════════════════════════════════════════════
// AREA LABELS (from changed files)
// ═══════════════════════════════════════════════════════════════
let files = [];
try {
const { data } = await github.rest.pulls.listFiles({
owner,
repo,
pull_number: prNumber,
per_page: 100
});
files = data;
} catch (e) {
console.log(` Could not fetch files: ${e.message}`);
}
const areas = {
frontend: false,
backend: false,
ci: false,
docs: false,
tests: false
};
for (const file of files) {
const path = file.filename;
if (path.startsWith('apps/frontend/')) areas.frontend = true;
if (path.startsWith('apps/backend/')) areas.backend = true;
if (path.startsWith('.github/')) areas.ci = true;
if (path.endsWith('.md') || path.startsWith('docs/')) areas.docs = true;
if (path.startsWith('tests/') || path.includes('.test.') || path.includes('.spec.')) areas.tests = true;
}
// Determine area label (mutually exclusive)
const areaLabels = ['area/frontend', 'area/backend', 'area/fullstack', 'area/ci'];
if (areas.frontend && areas.backend) {
labelsToAdd.add('area/fullstack');
areaLabels.filter(l => l !== 'area/fullstack').forEach(l => labelsToRemove.add(l));
console.log(` Area: fullstack (${files.length} files)`);
} else if (areas.frontend) {
labelsToAdd.add('area/frontend');
areaLabels.filter(l => l !== 'area/frontend').forEach(l => labelsToRemove.add(l));
console.log(` Area: frontend (${files.length} files)`);
} else if (areas.backend) {
labelsToAdd.add('area/backend');
areaLabels.filter(l => l !== 'area/backend').forEach(l => labelsToRemove.add(l));
console.log(` Area: backend (${files.length} files)`);
} else if (areas.ci) {
labelsToAdd.add('area/ci');
areaLabels.filter(l => l !== 'area/ci').forEach(l => labelsToRemove.add(l));
console.log(` Area: ci (${files.length} files)`);
}
// ═══════════════════════════════════════════════════════════════
// SIZE LABELS (from lines changed)
// ═══════════════════════════════════════════════════════════════
const additions = pr.additions || 0;
const deletions = pr.deletions || 0;
const totalLines = additions + deletions;
const sizeLabels = ['size/XS', 'size/S', 'size/M', 'size/L', 'size/XL'];
let sizeLabel;
if (totalLines < 10) sizeLabel = 'size/XS';
else if (totalLines < 100) sizeLabel = 'size/S';
else if (totalLines < 500) sizeLabel = 'size/M';
else if (totalLines < 1000) sizeLabel = 'size/L';
else sizeLabel = 'size/XL';
labelsToAdd.add(sizeLabel);
sizeLabels.filter(l => l !== sizeLabel).forEach(l => labelsToRemove.add(l));
console.log(` Size: ${sizeLabel} (+${additions}/-${deletions} = ${totalLines} lines)`);
console.log('::endgroup::');
// ═══════════════════════════════════════════════════════════════
// APPLY LABELS
// ═══════════════════════════════════════════════════════════════
console.log(`::group::Applying labels`);
// Remove old labels (in parallel)
const removeArray = [...labelsToRemove].filter(l => !labelsToAdd.has(l));
if (removeArray.length > 0) {
const removePromises = removeArray.map(async (label) => {
try {
await github.rest.issues.removeLabel({
owner,
repo,
issue_number: prNumber,
name: label
});
console.log(` Removed: ${label}`);
} catch (e) {
if (e.status !== 404) {
console.log(` Could not remove ${label}: ${e.message}`);
}
}
});
await Promise.all(removePromises);
}
// Add new labels
const addArray = [...labelsToAdd];
if (addArray.length > 0) {
try {
await github.rest.issues.addLabels({
owner,
repo,
issue_number: prNumber,
labels: addArray
});
console.log(` Added: ${addArray.join(', ')}`);
} catch (e) {
// Some labels might not exist - try one by one
if (e.status === 404) {
core.warning(`Some labels do not exist. Please create them in repository settings.`);
for (const label of addArray) {
try {
await github.rest.issues.addLabels({
owner,
repo,
issue_number: prNumber,
labels: [label]
});
} catch (e2) {
console.log(` Label '${label}' does not exist`);
}
}
} else {
throw e;
}
}
}
console.log('::endgroup::');
// Summary
console.log(`PR #${prNumber} labeled: ${addArray.join(', ')}`);
// Write job summary
core.summary
.addHeading(`PR #${prNumber} - Labels Applied`, 3)
.addTable([
[{data: 'Category', header: true}, {data: 'Label', header: true}],
['Status', '🔄 Checking'],
['Type', typeMatch ? typeMap[typeMatch[1].toLowerCase()] || 'none' : 'none'],
['Area', areas.frontend && areas.backend ? 'fullstack' : areas.frontend ? 'frontend' : areas.backend ? 'backend' : areas.ci ? 'ci' : 'other'],
['Size', sizeLabel]
])
.addRaw(`\n**Files changed:** ${files.length}\n`)
.addRaw(`**Lines:** +${additions} / -${deletions}\n`);
await core.summary.write();
# ═══════════════════════════════════════════════════════════════════════════
# STATUS UPDATE - Runs after CI workflow completes
# Updates PR label to "Ready for Review" or "Checks Failed"
# ═══════════════════════════════════════════════════════════════════════════
update-status:
name: Update PR Status
runs-on: ubuntu-latest
if: github.event_name == 'workflow_run' && github.event.workflow_run.pull_requests[0] != null
timeout-minutes: 5
steps:
- name: Check all required checks and update label
uses: actions/github-script@v7
with:
retries: 3
retry-exempt-status-codes: 400,401,403,404,422
script: |
const { owner, repo } = context.repo;
const prNumber = context.payload.workflow_run.pull_requests[0].number;
const headSha = context.payload.workflow_run.head_sha;
const triggerWorkflow = context.payload.workflow_run.name;
// ═══════════════════════════════════════════════════════════════
// REQUIRED CHECK RUNS - Job-level checks from consolidated CI
// ═══════════════════════════════════════════════════════════════
// Format: "{Workflow Name} / {Job Name}"
//
// To find check names: Go to PR -> Checks tab -> copy exact name
// To update: Edit this list when workflow jobs are added/renamed/removed
//
// Last validated: 2026-01-02
// ═══════════════════════════════════════════════════════════════
const requiredChecks = [
// CI workflow (ci.yml) - consolidated checks
'CI / Detect Changes',
'CI / test-frontend',
'CI / test-python (3.12)',
'CI / test-python (3.13)',
'CI / lint-python',
'CI / codeql (python)',
'CI / codeql (javascript-typescript)',
'CI / python-security',
'CI / security-summary',
'CI / CI Status'
];
const statusLabels = {
checking: '🔄 Checking',
passed: '✅ Ready for Review',
failed: '❌ Checks Failed'
};
console.log(`::group::PR #${prNumber} - Checking required checks`);
console.log(`Triggered by: ${triggerWorkflow}`);
console.log(`Head SHA: ${headSha}`);
console.log(`Required checks: ${requiredChecks.length}`);
console.log('');
// Fetch all check runs for this commit
let allCheckRuns = [];
try {
const { data } = await github.rest.checks.listForRef({
owner,
repo,
ref: headSha,
per_page: 100
});
allCheckRuns = data.check_runs;
console.log(`Found ${allCheckRuns.length} total check runs`);
} catch (error) {
core.warning(`Failed to fetch check runs for PR #${prNumber}: ${error.message}. PR label may be outdated.`);
console.log(`::error::Failed to fetch check runs: ${error.message}`);
console.log('::endgroup::');
return;
}
let allComplete = true;
let anyFailed = false;
const results = [];
// Check each required check
for (const checkName of requiredChecks) {
const check = allCheckRuns.find(c => c.name === checkName);
if (!check) {
results.push({ name: checkName, status: 'Pending', complete: false });
allComplete = false;
} else if (check.status !== 'completed') {
results.push({ name: checkName, status: 'Running', complete: false });
allComplete = false;
} else if (check.conclusion === 'success') {
results.push({ name: checkName, status: 'Passed', complete: true });
} else if (check.conclusion === 'skipped') {
// Skipped checks are treated as passed (e.g., path filters, conditional jobs)
results.push({ name: checkName, status: 'Skipped', complete: true, skipped: true });
} else {
results.push({ name: checkName, status: 'Failed', complete: true, failed: true });
anyFailed = true;
}
}
// Print results table
console.log('');
console.log('Check Status:');
console.log('-'.repeat(70));
for (const r of results) {
const shortName = r.name.length > 55 ? r.name.substring(0, 52) + '...' : r.name;
console.log(` ${r.status.padEnd(12)} ${shortName}`);
}
console.log('-'.repeat(70));
console.log('::endgroup::');
// Only update label if all required checks are complete
if (!allComplete) {
const pending = results.filter(r => !r.complete).length;
console.log(`${pending}/${requiredChecks.length} checks still pending - keeping current label`);
return;
}
// Determine final label
const newLabel = anyFailed ? statusLabels.failed : statusLabels.passed;
console.log(`::group::Updating PR #${prNumber} label`);
// Remove old status labels
for (const label of Object.values(statusLabels)) {
try {
await github.rest.issues.removeLabel({
owner,
repo,
issue_number: prNumber,
name: label
});
console.log(` Removed: ${label}`);
} catch (e) {
if (e.status !== 404) {
console.log(` Could not remove ${label}: ${e.message}`);
}
}
}
// Add final status label
try {
await github.rest.issues.addLabels({
owner,
repo,
issue_number: prNumber,
labels: [newLabel]
});
console.log(` Added: ${newLabel}`);
} catch (e) {
if (e.status === 404) {
core.warning(`Label '${newLabel}' does not exist. Please create it in repository settings.`);
}
throw e;
}
console.log('::endgroup::');
// Summary
const passedCount = results.filter(r => r.status === 'Passed').length;
const skippedCount = results.filter(r => r.skipped).length;
const failedCount = results.filter(r => r.failed).length;
if (anyFailed) {
console.log(`PR #${prNumber} has ${failedCount} failing check(s)`);
core.summary.addRaw(`## PR #${prNumber} - Checks Failed\n\n`);
core.summary.addRaw(`**${failedCount}** of **${requiredChecks.length}** required checks failed.\n\n`);
} else {
const skippedNote = skippedCount > 0 ? ` (${skippedCount} skipped)` : '';
const totalSuccessful = passedCount + skippedCount;
console.log(`PR #${prNumber} is ready for review (${totalSuccessful}/${requiredChecks.length} checks succeeded${skippedNote})`);
core.summary.addRaw(`## PR #${prNumber} - Ready for Review\n\n`);
core.summary.addRaw(`All **${requiredChecks.length}** required checks succeeded${skippedNote}.\n\n`);
}
// Add results to summary
core.summary.addTable([
[{data: 'Check', header: true}, {data: 'Status', header: true}],
...results.map(r => [r.name, r.status])
]);
await core.summary.write();