Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d789d0ad15 | |||
| 324edfa7d5 | |||
| b5f452b428 | |||
| b0de22a988 | |||
| 3c49173210 | |||
| d3587c3c42 | |||
| f6c9a84acb | |||
| 91b80c0df0 | |||
| 052e6a0970 | |||
| 90f5b59c35 |
+693
-30
@@ -1,49 +1,386 @@
|
||||
# ╔═══════════════════════════════════════════════════════════════════════════════╗
|
||||
# ║ ║
|
||||
# ║ AUTO CLAUDE - CI PIPELINE ║
|
||||
# ║ ║
|
||||
# ║ A unified, enterprise-grade CI workflow for pull request validation ║
|
||||
# ║ and scheduled security scanning. ║
|
||||
# ║ ║
|
||||
# ║ TRIGGERS: ║
|
||||
# ║ - Pull requests to main/develop branches ║
|
||||
# ║ - Weekly scheduled security scans (Monday 00:00 UTC) ║
|
||||
# ║ - Manual workflow dispatch ║
|
||||
# ║ ║
|
||||
# ║ 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 (PR mode): ║
|
||||
# ║ - 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 ║
|
||||
# ║ ║
|
||||
# ║ SCHEDULED SECURITY SCANS: ║
|
||||
# ║ - Weekly CodeQL analysis for Python and JavaScript/TypeScript ║
|
||||
# ║ - Weekly Bandit security scan for Python backend ║
|
||||
# ║ - Detects newly discovered CVEs in existing code ║
|
||||
# ║ ║
|
||||
# ║ LABELS APPLIED (PR mode only): ║
|
||||
# ║ - 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
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, develop]
|
||||
pull_request:
|
||||
branches: [main, develop]
|
||||
types: [opened, synchronize, reopened]
|
||||
# Weekly scheduled security scans to detect newly discovered CVEs
|
||||
# Runs every Monday at midnight UTC
|
||||
schedule:
|
||||
- cron: '0 0 * * 1'
|
||||
# Allow manual triggering for security scans
|
||||
workflow_dispatch:
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
# CONCURRENCY: Cancel redundant runs when new commits are pushed to the same PR
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
concurrency:
|
||||
group: ci-${{ github.event.pull_request.number || github.ref }}
|
||||
group: ci-${{ github.head_ref || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
# PERMISSIONS: Minimal permissions required for this workflow
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
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:
|
||||
# Python tests
|
||||
test-python:
|
||||
|
||||
# ┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
# │ 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) and scheduled runs (no PR context)
|
||||
if: github.event_name == 'pull_request' && 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 }}
|
||||
skip_tests: ${{ steps.evaluate.outputs.skip_tests }}
|
||||
scheduled_scan: ${{ steps.evaluate.outputs.scheduled_scan }}
|
||||
|
||||
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'
|
||||
frontend:
|
||||
- 'apps/frontend/**'
|
||||
- 'package*.json'
|
||||
any_code:
|
||||
- 'apps/**'
|
||||
- 'tests/**'
|
||||
- 'package*.json'
|
||||
- 'requirements*.txt'
|
||||
|
||||
- name: "2.3 Evaluate Test Requirements"
|
||||
id: evaluate
|
||||
run: |
|
||||
echo ""
|
||||
echo "═══════════════════════════════════════════════════════════"
|
||||
echo " CHANGE DETECTION RESULTS"
|
||||
echo "═══════════════════════════════════════════════════════════"
|
||||
echo ""
|
||||
|
||||
# For scheduled runs, always run security scans on all code
|
||||
if [ "${{ github.event_name }}" = "schedule" ] || [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
echo " Event type: ${{ github.event_name }}"
|
||||
echo " → Scheduled/manual security scan - running all checks"
|
||||
echo ""
|
||||
echo "skip_tests=false" >> $GITHUB_OUTPUT
|
||||
echo "scheduled_scan=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo " Backend changes: ${{ steps.filter.outputs.backend }}"
|
||||
echo " Frontend changes: ${{ steps.filter.outputs.frontend }}"
|
||||
echo " Any code changes: ${{ steps.filter.outputs.any_code }}"
|
||||
echo ""
|
||||
echo "scheduled_scan=false" >> $GITHUB_OUTPUT
|
||||
|
||||
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
|
||||
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
|
||||
@@ -51,16 +388,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:
|
||||
@@ -69,44 +410,366 @@ jobs:
|
||||
env:
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
# Frontend lint, typecheck, test, and build
|
||||
test-frontend:
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 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 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: "3.1 Checkout Repository"
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: "3.2 Setup Python"
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
# Ruff version pinned to match .pre-commit-config.yaml
|
||||
- name: "3.3 Install Ruff"
|
||||
run: pip install ruff==0.14.10
|
||||
|
||||
- name: "3.4 Run Ruff Linter"
|
||||
run: ruff check apps/backend/ --output-format=github
|
||||
|
||||
- name: "3.5 Check Code Formatting"
|
||||
run: ruff format apps/backend/ --check --diff
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# CODEQL: Static analysis for security vulnerabilities
|
||||
# Triggered: When any code files change OR on scheduled/manual security scans
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
stage-3-codeql:
|
||||
name: "Stage 3: CodeQL (${{ matrix.language }})"
|
||||
runs-on: ubuntu-latest
|
||||
needs: stage-2-changes
|
||||
# Run on code changes OR scheduled/manual security scans
|
||||
if: needs.stage-2-changes.outputs.any_code == 'true' || needs.stage-2-changes.outputs.scheduled_scan == 'true'
|
||||
timeout-minutes: 30
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
language: [python, javascript-typescript]
|
||||
|
||||
steps:
|
||||
- name: "3.1 Checkout Repository"
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: "3.2 Initialize CodeQL"
|
||||
uses: github/codeql-action/init@v3
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
queries: +security-extended,security-and-quality
|
||||
|
||||
- name: "3.3 Autobuild"
|
||||
uses: github/codeql-action/autobuild@v3
|
||||
|
||||
- name: "3.4 Run CodeQL Analysis"
|
||||
uses: github/codeql-action/analyze@v3
|
||||
with:
|
||||
category: "/language:${{ matrix.language }}"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# PYTHON SECURITY: Bandit security scanner for Python code
|
||||
# Triggered: When backend files change OR on scheduled/manual security scans
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
stage-3-security-python:
|
||||
name: "Stage 3: Python Security"
|
||||
runs-on: ubuntu-latest
|
||||
needs: stage-2-changes
|
||||
# Run on backend changes OR scheduled/manual security scans
|
||||
if: needs.stage-2-changes.outputs.backend == 'true' || needs.stage-2-changes.outputs.scheduled_scan == 'true'
|
||||
timeout-minutes: 10
|
||||
|
||||
steps:
|
||||
- name: "3.1 Checkout Repository"
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: "3.2 Setup Python"
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
- name: "3.3 Install Bandit"
|
||||
run: pip install bandit
|
||||
|
||||
- name: "3.4 Run Bandit Security Scan"
|
||||
id: bandit
|
||||
run: |
|
||||
# Run Bandit and capture exit code
|
||||
# Exit 0 = no issues, Exit 1 = issues found, Exit > 1 = real error
|
||||
set +e
|
||||
bandit -r apps/backend/ -ll -ii -f json -o bandit-report.json
|
||||
BANDIT_EXIT=$?
|
||||
set -e
|
||||
|
||||
if [ $BANDIT_EXIT -eq 0 ]; then
|
||||
echo "✓ Bandit scan completed - no issues found"
|
||||
echo "scan_status=clean" >> $GITHUB_OUTPUT
|
||||
elif [ $BANDIT_EXIT -eq 1 ]; then
|
||||
echo "⚠ Bandit scan completed - security issues found"
|
||||
echo "scan_status=issues_found" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "✗ Bandit scan failed with exit code $BANDIT_EXIT"
|
||||
echo " This indicates a configuration error or missing directory"
|
||||
exit $BANDIT_EXIT
|
||||
fi
|
||||
|
||||
- name: "3.5 Analyze Security Results"
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
const scanStatus = '${{ steps.bandit.outputs.scan_status }}';
|
||||
|
||||
if (!fs.existsSync('bandit-report.json')) {
|
||||
core.setFailed('Bandit report not found - scan failed before producing output');
|
||||
return;
|
||||
}
|
||||
|
||||
// If scan was clean, we can skip detailed analysis
|
||||
if (scanStatus === 'clean') {
|
||||
console.log('\n' + '═'.repeat(60));
|
||||
console.log(' BANDIT SECURITY SCAN RESULTS');
|
||||
console.log('═'.repeat(60));
|
||||
console.log('\n✓ No security issues found\n');
|
||||
console.log('═'.repeat(60) + '\n');
|
||||
return;
|
||||
}
|
||||
|
||||
const report = JSON.parse(fs.readFileSync('bandit-report.json', 'utf8'));
|
||||
const results = report.results || [];
|
||||
|
||||
// Categorize by severity
|
||||
const high = results.filter(r => r.issue_severity === 'HIGH');
|
||||
const medium = results.filter(r => r.issue_severity === 'MEDIUM');
|
||||
const low = results.filter(r => r.issue_severity === 'LOW');
|
||||
|
||||
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}\n`);
|
||||
|
||||
if (high.length > 0) {
|
||||
console.log('─'.repeat(60));
|
||||
console.log(' HIGH SEVERITY ISSUES:');
|
||||
console.log('─'.repeat(60));
|
||||
for (const issue of high) {
|
||||
console.log(`\n 📍 ${issue.filename}:${issue.line_number}`);
|
||||
console.log(` ${issue.issue_text}`);
|
||||
console.log(` Test: ${issue.test_id} (${issue.test_name})`);
|
||||
}
|
||||
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('═'.repeat(60) + '\n');
|
||||
}
|
||||
|
||||
# ┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
# │ 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
|
||||
# For scheduled runs, skip PR label updates (no PR context)
|
||||
if: always() && (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch')
|
||||
timeout-minutes: 5
|
||||
|
||||
steps:
|
||||
- name: "4.1 Evaluate CI Results and Update PR"
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
const eventName = '${{ github.event_name }}';
|
||||
const isScheduledRun = eventName === 'schedule' || eventName === 'workflow_dispatch';
|
||||
const prNumber = context.payload.pull_request?.number;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// COLLECT RESULTS FROM ALL QUALITY GATE JOBS
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
const results = {
|
||||
'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 }}'
|
||||
};
|
||||
|
||||
const skipTests = '${{ needs.stage-2-changes.outputs.skip_tests }}' === 'true';
|
||||
|
||||
console.log('\n' + '═'.repeat(60));
|
||||
if (isScheduledRun) {
|
||||
console.log(' SCHEDULED SECURITY SCAN RESULTS');
|
||||
} else {
|
||||
console.log(' CI PIPELINE RESULTS');
|
||||
}
|
||||
console.log('═'.repeat(60) + '\n');
|
||||
|
||||
if (skipTests && !isScheduledRun) {
|
||||
console.log(' ℹ️ No code changes detected - tests were skipped\n');
|
||||
}
|
||||
|
||||
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, _]) => job);
|
||||
|
||||
const statusLabels = {
|
||||
checking: '🔄 Checking',
|
||||
passed: '✅ Ready for Review',
|
||||
failed: '❌ Checks Failed'
|
||||
};
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// UPDATE PR LABELS (skip for scheduled runs - no PR context)
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
if (!isScheduledRun && prNumber) {
|
||||
// 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) {
|
||||
const resultType = isScheduledRun ? 'SECURITY SCAN FAILED' : 'CI FAILED';
|
||||
console.log(` ❌ RESULT: ${resultType}`);
|
||||
console.log(` Failed jobs: ${failed.join(', ')}`);
|
||||
console.log('\n' + '═'.repeat(60) + '\n');
|
||||
|
||||
core.summary.addRaw(`## ❌ ${resultType}\n\n`);
|
||||
core.summary.addRaw(`The following checks failed:\n`);
|
||||
for (const job of failed) {
|
||||
core.summary.addRaw(`- ${job}\n`);
|
||||
}
|
||||
core.setFailed(`${resultType}: ${failed.join(', ')}`);
|
||||
|
||||
} else if (isScheduledRun) {
|
||||
console.log(` ✅ RESULT: SECURITY SCAN PASSED`);
|
||||
console.log(` All security checks passed`);
|
||||
console.log('\n' + '═'.repeat(60) + '\n');
|
||||
|
||||
core.summary.addRaw(`## ✅ Weekly Security Scan Passed\n\n`);
|
||||
core.summary.addRaw(`All scheduled security checks (CodeQL, Bandit) completed successfully.\n`);
|
||||
|
||||
} 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();
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
name: Community
|
||||
|
||||
# Consolidated community automation:
|
||||
# - Welcome messages for first-time contributors (issues & PRs)
|
||||
# - Auto-label issues based on form selection
|
||||
# - Mark and close stale issues
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened]
|
||||
pull_request_target:
|
||||
types: [opened]
|
||||
schedule:
|
||||
- cron: '0 0 * * 0' # Every Sunday at midnight UTC
|
||||
workflow_dispatch: # Allow manual trigger for stale check
|
||||
|
||||
jobs:
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# WELCOME - First-time contributor welcome messages
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
welcome:
|
||||
name: Welcome New Contributors
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'pull_request_target' || github.event_name == 'issues'
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/first-interaction@v1.4.0
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
issue-message: |
|
||||
Thanks for opening your first issue!
|
||||
|
||||
A maintainer will triage this soon. In the meantime:
|
||||
- Make sure you've provided all the requested info
|
||||
- Join our [Discord](https://discord.gg/QhRnz9m5HE) for faster help
|
||||
pr-message: |
|
||||
Thanks for your first PR!
|
||||
|
||||
A maintainer will review it soon. Please make sure:
|
||||
- Your branch is synced with `develop`
|
||||
- CI checks pass
|
||||
- You've followed our [contribution guide](https://github.com/AndyMik90/Auto-Claude/blob/develop/CONTRIBUTING.md)
|
||||
|
||||
Welcome to the Auto Claude community!
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# ISSUE LABELS - Auto-label issues based on form area selection
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
issue-labels:
|
||||
name: Label Issue by Area
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'issues'
|
||||
permissions:
|
||||
issues: write
|
||||
steps:
|
||||
- name: Add area label from form
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const issue = context.payload.issue;
|
||||
const body = issue.body || '';
|
||||
|
||||
console.log(`Processing issue #${issue.number}: ${issue.title}`);
|
||||
|
||||
// Map form selection to label
|
||||
const areaMap = {
|
||||
'Frontend': 'area/frontend',
|
||||
'Backend': 'area/backend',
|
||||
'Fullstack': 'area/fullstack'
|
||||
};
|
||||
|
||||
const labels = [];
|
||||
|
||||
for (const [key, label] of Object.entries(areaMap)) {
|
||||
if (body.includes(key)) {
|
||||
console.log(`Found area: ${key}, adding label: ${label}`);
|
||||
labels.push(label);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (labels.length > 0) {
|
||||
try {
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
labels: labels
|
||||
});
|
||||
console.log(`Successfully added labels: ${labels.join(', ')}`);
|
||||
} catch (error) {
|
||||
core.setFailed(`Failed to add labels: ${error.message}`);
|
||||
}
|
||||
} else {
|
||||
console.log('No matching area found in issue body');
|
||||
}
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# STALE - Mark and close inactive issues
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
stale:
|
||||
name: Mark Stale Issues
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
|
||||
permissions:
|
||||
issues: write
|
||||
steps:
|
||||
- uses: actions/stale@v9
|
||||
with:
|
||||
stale-issue-message: |
|
||||
This issue has been inactive for 60 days. It will be closed in 14 days if there's no activity.
|
||||
|
||||
- If this is still relevant, please comment or update the issue
|
||||
- If you're working on this, add the `in-progress` label
|
||||
close-issue-message: 'Closed due to inactivity. Feel free to reopen if still relevant.'
|
||||
stale-issue-label: 'stale'
|
||||
days-before-stale: 60
|
||||
days-before-close: 14
|
||||
exempt-issue-labels: 'priority/critical,priority/high,in-progress,blocked'
|
||||
@@ -1,53 +0,0 @@
|
||||
name: Issue Auto Label
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened]
|
||||
|
||||
jobs:
|
||||
label-area:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
steps:
|
||||
- name: Add area label from form
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const issue = context.payload.issue;
|
||||
const body = issue.body || '';
|
||||
|
||||
console.log(`Processing issue #${issue.number}: ${issue.title}`);
|
||||
|
||||
// Map form selection to label
|
||||
const areaMap = {
|
||||
'Frontend': 'area/frontend',
|
||||
'Backend': 'area/backend',
|
||||
'Fullstack': 'area/fullstack'
|
||||
};
|
||||
|
||||
const labels = [];
|
||||
|
||||
for (const [key, label] of Object.entries(areaMap)) {
|
||||
if (body.includes(key)) {
|
||||
console.log(`Found area: ${key}, adding label: ${label}`);
|
||||
labels.push(label);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (labels.length > 0) {
|
||||
try {
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
labels: labels
|
||||
});
|
||||
console.log(`Successfully added labels: ${labels.join(', ')}`);
|
||||
} catch (error) {
|
||||
core.setFailed(`Failed to add labels: ${error.message}`);
|
||||
}
|
||||
} else {
|
||||
console.log('No matching area found in issue body');
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
name: Lint
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, develop]
|
||||
pull_request:
|
||||
branches: [main, develop]
|
||||
|
||||
concurrency:
|
||||
group: lint-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# Python linting
|
||||
python:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up 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
|
||||
run: pip install ruff==0.14.10
|
||||
|
||||
- name: Run ruff check
|
||||
run: ruff check apps/backend/ --output-format=github
|
||||
|
||||
- name: Run ruff format check
|
||||
run: ruff format apps/backend/ --check --diff
|
||||
@@ -1,227 +0,0 @@
|
||||
name: PR Auto Label
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
|
||||
# Cancel in-progress runs for the same PR
|
||||
concurrency:
|
||||
group: pr-auto-label-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
label:
|
||||
name: Auto Label PR
|
||||
runs-on: ubuntu-latest
|
||||
# Don't run on fork PRs (they can't write labels)
|
||||
if: github.event.pull_request.head.repo.full_name == github.repository
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Auto-label PR
|
||||
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`);
|
||||
console.log(`Title: ${title}`);
|
||||
|
||||
const labelsToAdd = new Set();
|
||||
const labelsToRemove = new Set();
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// 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
|
||||
if (e.status === 404) {
|
||||
core.warning(`Some labels do not exist. Please create them in repository settings.`);
|
||||
// Try adding 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`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('::endgroup::');
|
||||
|
||||
// Summary
|
||||
console.log(`✅ PR #${prNumber} labeled: ${addArray.join(', ')}`);
|
||||
|
||||
// Write job summary
|
||||
core.summary
|
||||
.addHeading(`PR #${prNumber} Auto-Labels`, 3)
|
||||
.addTable([
|
||||
[{data: 'Category', header: true}, {data: 'Label', header: true}],
|
||||
['Type', typeMatch ? typeMap[typeMatch[1].toLowerCase()] || 'none' : 'none'],
|
||||
['Area', areas.frontend && areas.backend ? 'fullstack' : areas.frontend ? 'frontend' : areas.backend ? 'backend' : 'other'],
|
||||
['Size', sizeLabel]
|
||||
])
|
||||
.addRaw(`\n**Files changed:** ${files.length}\n`)
|
||||
.addRaw(`**Lines:** +${additions} / -${deletions}\n`);
|
||||
await core.summary.write();
|
||||
@@ -1,72 +0,0 @@
|
||||
name: PR Status Check
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
|
||||
# Cancel in-progress runs for the same PR
|
||||
concurrency:
|
||||
group: pr-status-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
mark-checking:
|
||||
name: Set Checking Status
|
||||
runs-on: ubuntu-latest
|
||||
# Don't run on fork PRs (they can't write labels)
|
||||
if: github.event.pull_request.head.repo.full_name == github.repository
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Update PR status 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.pull_request.number;
|
||||
const statusLabels = ['🔄 Checking', '✅ Ready for Review', '❌ Checks Failed'];
|
||||
|
||||
console.log(`::group::PR #${prNumber} - Setting status to Checking`);
|
||||
|
||||
// Remove old status labels (parallel for speed)
|
||||
const removePromises = statusLabels.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 checking label
|
||||
try {
|
||||
await github.rest.issues.addLabels({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
labels: ['🔄 Checking']
|
||||
});
|
||||
console.log(` ✓ Added: 🔄 Checking`);
|
||||
} catch (e) {
|
||||
// Label might not exist - create helpful error
|
||||
if (e.status === 404) {
|
||||
core.warning(`Label '🔄 Checking' does not exist. Please create it in repository settings.`);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
||||
console.log('::endgroup::');
|
||||
console.log(`✅ PR #${prNumber} marked as checking`);
|
||||
@@ -1,191 +0,0 @@
|
||||
name: PR Status Gate
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: [CI, Lint, Quality Security]
|
||||
types: [completed]
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
checks: read
|
||||
|
||||
jobs:
|
||||
update-status:
|
||||
name: Update PR Status
|
||||
runs-on: ubuntu-latest
|
||||
# Only run if this workflow_run is associated with a PR
|
||||
if: 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 (not workflow-level)
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// Format: "{Workflow Name} / {Job Name}" or "{Workflow Name} / {Job Custom 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) - 3 checks
|
||||
'CI / test-frontend',
|
||||
'CI / test-python (3.12)',
|
||||
'CI / test-python (3.13)',
|
||||
// Lint workflow (lint.yml) - 1 check
|
||||
'Lint / python',
|
||||
// Quality Security workflow (quality-security.yml) - 4 checks
|
||||
'Quality Security / CodeQL (javascript-typescript)',
|
||||
'Quality Security / CodeQL (python)',
|
||||
'Quality Security / Python Security (Bandit)',
|
||||
'Quality Security / Security Summary'
|
||||
];
|
||||
|
||||
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) {
|
||||
// Add warning annotation so maintainers are alerted
|
||||
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();
|
||||
@@ -34,6 +34,70 @@ jobs:
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "Package version: $VERSION"
|
||||
|
||||
# Validate all version files are in sync before proceeding
|
||||
- name: Validate version sync
|
||||
run: |
|
||||
echo "Validating version synchronization across all files..."
|
||||
|
||||
ROOT_VERSION=$(node -p "require('./package.json').version")
|
||||
FRONTEND_VERSION=$(node -p "require('./apps/frontend/package.json').version")
|
||||
# Extract Python version - handles both formats: __version__ = "X.Y.Z" or __version__="X.Y.Z"
|
||||
BACKEND_VERSION=$(grep -oP '__version__\s*=\s*["\x27]\K[^"\x27]+' apps/backend/__init__.py || echo "NOT_FOUND")
|
||||
|
||||
echo "=========================================="
|
||||
echo "Version Sync Validation"
|
||||
echo "=========================================="
|
||||
echo "Root package.json: $ROOT_VERSION"
|
||||
echo "Frontend package.json: $FRONTEND_VERSION"
|
||||
echo "Backend __init__.py: $BACKEND_VERSION"
|
||||
echo "=========================================="
|
||||
|
||||
ERRORS=0
|
||||
|
||||
if [ "$ROOT_VERSION" != "$FRONTEND_VERSION" ]; then
|
||||
echo "::error::Version mismatch: root package.json ($ROOT_VERSION) != frontend package.json ($FRONTEND_VERSION)"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
|
||||
if [ "$BACKEND_VERSION" = "NOT_FOUND" ]; then
|
||||
echo "::error::Could not extract version from apps/backend/__init__.py"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
elif [ "$ROOT_VERSION" != "$BACKEND_VERSION" ]; then
|
||||
echo "::error::Version mismatch: root package.json ($ROOT_VERSION) != backend __init__.py ($BACKEND_VERSION)"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
|
||||
if [ $ERRORS -gt 0 ]; then
|
||||
echo ""
|
||||
echo "::error::═══════════════════════════════════════════════════════════════════════"
|
||||
echo "::error:: VERSION SYNC FAILED"
|
||||
echo "::error::═══════════════════════════════════════════════════════════════════════"
|
||||
echo "::error::"
|
||||
echo "::error:: All version files must be in sync before releasing."
|
||||
echo "::error::"
|
||||
echo "::error:: To fix this, use the bump-version script:"
|
||||
echo "::error:: node scripts/bump-version.js <patch|minor|major|X.Y.Z>"
|
||||
echo "::error::"
|
||||
echo "::error:: This will update all version files automatically."
|
||||
echo "::error::═══════════════════════════════════════════════════════════════════════"
|
||||
|
||||
# Add to job summary
|
||||
echo "## Version Sync Failed" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| File | Version |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "|------|---------|" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| package.json | $ROOT_VERSION |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| apps/frontend/package.json | $FRONTEND_VERSION |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| apps/backend/__init__.py | $BACKEND_VERSION |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Use \`node scripts/bump-version.js <version>\` to sync all files." >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "All version files are in sync: $ROOT_VERSION"
|
||||
|
||||
- name: Get latest tag version
|
||||
id: latest_tag
|
||||
run: |
|
||||
|
||||
@@ -1,178 +0,0 @@
|
||||
name: Quality Security
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, develop]
|
||||
pull_request:
|
||||
branches: [main, develop]
|
||||
schedule:
|
||||
- cron: '0 0 * * 1' # Weekly on Monday at midnight UTC
|
||||
|
||||
# Cancel in-progress runs for the same branch/PR
|
||||
concurrency:
|
||||
group: security-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
security-events: write
|
||||
actions: read
|
||||
|
||||
jobs:
|
||||
codeql:
|
||||
name: CodeQL (${{ matrix.language }})
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
language: [python, javascript-typescript]
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v3
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
queries: +security-extended,security-and-quality
|
||||
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v3
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v3
|
||||
with:
|
||||
category: "/language:${{ matrix.language }}"
|
||||
|
||||
python-security:
|
||||
name: Python Security (Bandit)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
- name: Install Bandit
|
||||
run: pip install bandit
|
||||
|
||||
- name: Run Bandit security scan
|
||||
id: bandit
|
||||
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::"
|
||||
|
||||
- name: Analyze Bandit 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;
|
||||
}
|
||||
|
||||
const report = JSON.parse(fs.readFileSync('bandit-report.json', 'utf8'));
|
||||
const results = report.results || [];
|
||||
|
||||
// Categorize by severity
|
||||
const high = results.filter(r => r.issue_severity === 'HIGH');
|
||||
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(` 🟡 MEDIUM: ${medium.length}`);
|
||||
console.log(` 🟢 LOW: ${low.length}`);
|
||||
console.log('');
|
||||
|
||||
// Print high severity issues
|
||||
if (high.length > 0) {
|
||||
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('::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) {
|
||||
core.setFailed(`Found ${high.length} high severity security issue(s)`);
|
||||
} else {
|
||||
console.log('✅ No high severity security issues found');
|
||||
}
|
||||
|
||||
# Summary job that waits for all security checks
|
||||
security-summary:
|
||||
name: Security Summary
|
||||
runs-on: ubuntu-latest
|
||||
needs: [codeql, python-security]
|
||||
if: always()
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Check security results
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const codeql = '${{ needs.codeql.result }}';
|
||||
const bandit = '${{ needs.python-security.result }}';
|
||||
|
||||
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('\n✅ All security checks passed');
|
||||
core.summary.addRaw('## ✅ Security Checks Passed\n\nAll security scans completed successfully.');
|
||||
} else {
|
||||
console.log('\n❌ Some 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();
|
||||
@@ -1,25 +0,0 @@
|
||||
name: Stale Issues
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 0 * * 0' # Every Sunday
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
steps:
|
||||
- uses: actions/stale@v9
|
||||
with:
|
||||
stale-issue-message: |
|
||||
This issue has been inactive for 60 days. It will be closed in 14 days if there's no activity.
|
||||
|
||||
- If this is still relevant, please comment or update the issue
|
||||
- If you're working on this, add the `in-progress` label
|
||||
close-issue-message: 'Closed due to inactivity. Feel free to reopen if still relevant.'
|
||||
stale-issue-label: 'stale'
|
||||
days-before-stale: 60
|
||||
days-before-close: 14
|
||||
exempt-issue-labels: 'priority/critical,priority/high,in-progress,blocked'
|
||||
@@ -1,63 +0,0 @@
|
||||
name: Test on Tag
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
# Python tests
|
||||
test-python:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ['3.12', '3.13']
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v4
|
||||
with:
|
||||
version: "latest"
|
||||
|
||||
- name: 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
|
||||
working-directory: apps/backend
|
||||
env:
|
||||
PYTHONPATH: ${{ github.workspace }}/apps/backend
|
||||
run: |
|
||||
source .venv/bin/activate
|
||||
pytest ../../tests/ -v --tb=short
|
||||
|
||||
# Frontend tests
|
||||
test-frontend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: apps/frontend
|
||||
run: npm ci --ignore-scripts
|
||||
|
||||
- name: Run tests
|
||||
working-directory: apps/frontend
|
||||
run: npm run test
|
||||
@@ -1,71 +0,0 @@
|
||||
name: Validate Version
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
validate-version:
|
||||
name: Validate package.json version matches tag
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Extract version from tag
|
||||
id: tag_version
|
||||
run: |
|
||||
# Extract version from tag (e.g., v2.5.5 -> 2.5.5)
|
||||
TAG_VERSION=${GITHUB_REF#refs/tags/v}
|
||||
echo "version=$TAG_VERSION" >> $GITHUB_OUTPUT
|
||||
echo "Tag version: $TAG_VERSION"
|
||||
|
||||
- name: Extract version from package.json
|
||||
id: package_version
|
||||
run: |
|
||||
# Read version from package.json
|
||||
PACKAGE_VERSION=$(node -p "require('./apps/frontend/package.json').version")
|
||||
echo "version=$PACKAGE_VERSION" >> $GITHUB_OUTPUT
|
||||
echo "Package.json version: $PACKAGE_VERSION"
|
||||
|
||||
- name: Compare versions
|
||||
run: |
|
||||
TAG_VERSION="${{ steps.tag_version.outputs.version }}"
|
||||
PACKAGE_VERSION="${{ steps.package_version.outputs.version }}"
|
||||
|
||||
echo "=========================================="
|
||||
echo "Version Validation"
|
||||
echo "=========================================="
|
||||
echo "Git tag version: v$TAG_VERSION"
|
||||
echo "package.json version: $PACKAGE_VERSION"
|
||||
echo "=========================================="
|
||||
|
||||
if [ "$TAG_VERSION" != "$PACKAGE_VERSION" ]; then
|
||||
echo ""
|
||||
echo "❌ ERROR: Version mismatch detected!"
|
||||
echo ""
|
||||
echo "The version in package.json ($PACKAGE_VERSION) does not match"
|
||||
echo "the git tag version ($TAG_VERSION)."
|
||||
echo ""
|
||||
echo "To fix this:"
|
||||
echo " 1. Delete this tag: git tag -d v$TAG_VERSION"
|
||||
echo " 2. Update package.json version to $TAG_VERSION"
|
||||
echo " 3. Commit the change"
|
||||
echo " 4. Recreate the tag: git tag -a v$TAG_VERSION -m 'Release v$TAG_VERSION'"
|
||||
echo ""
|
||||
echo "Or use the automated script:"
|
||||
echo " node scripts/bump-version.js $TAG_VERSION"
|
||||
echo ""
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "✅ SUCCESS: Versions match!"
|
||||
echo ""
|
||||
|
||||
- name: Version validation result
|
||||
if: success()
|
||||
run: |
|
||||
echo "::notice::Version validation passed - package.json version matches tag v${{ steps.tag_version.outputs.version }}"
|
||||
@@ -1,33 +0,0 @@
|
||||
name: Welcome
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened]
|
||||
issues:
|
||||
types: [opened]
|
||||
|
||||
jobs:
|
||||
welcome:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/first-interaction@v1
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
issue-message: |
|
||||
👋 Thanks for opening your first issue!
|
||||
|
||||
A maintainer will triage this soon. In the meantime:
|
||||
- Make sure you've provided all the requested info
|
||||
- Join our [Discord](https://discord.gg/QhRnz9m5HE) for faster help
|
||||
pr-message: |
|
||||
🎉 Thanks for your first PR!
|
||||
|
||||
A maintainer will review it soon. Please make sure:
|
||||
- Your branch is synced with `develop`
|
||||
- CI checks pass
|
||||
- You've followed our [contribution guide](https://github.com/AndyMik90/Auto-Claude/blob/develop/CONTRIBUTING.md)
|
||||
|
||||
Welcome to the Auto Claude community!
|
||||
Reference in New Issue
Block a user