feat: enhance the logs for the commit linting stage (#293)
* ci: implement enterprise-grade PR quality gates and security scanning * ci: implement enterprise-grade PR quality gates and security scanning * fix:pr comments and improve code * fix: improve commit linting and code quality * Removed the dependency-review job (i added it) * fix: address CodeRabbit review comments - Expand scope pattern to allow uppercase, underscores, slashes, dots - Add concurrency control to cancel duplicate security scan runs - Add explanatory comment for Bandit CLI flags - Remove dependency-review job (requires repo settings) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * docs: update commit lint examples with expanded scope patterns Show slashes and dots in scope examples to demonstrate the newly allowed characters (api/users, package.json) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * chore: remove feature request issue template Feature requests are directed to GitHub Discussions via the issue template config.yml 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: address security vulnerabilities in service orchestrator - Fix port parsing crash on malformed docker-compose entries - Fix shell injection risk by using shlex.split() with shell=False Prevents crashes when docker-compose.yml contains environment variables in port mappings (e.g., '${PORT}:8080') and eliminates shell injection vulnerabilities in subprocess execution. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * fix(ci): improve PR title validation error messages with examples Add helpful console output when PR title validation fails: - Show expected format and valid types - Provide examples of valid PR titles - Display the user's current title - Suggest fixes based on keywords in the title - Handle verb variations (fixed, adding, updated, etc.) - Show placeholder when description is empty after cleanup 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * lower coverage * feat: improve status gate to label correctly based on required checks * fix(ci): address PR review findings for security and efficiency - Add explicit permissions block to ci.yml (least privilege principle) - Skip duplicate test run for Python 3.12 (tests with coverage only) - Sanitize PR title in markdown output to prevent injection 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix typo --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,10 @@ on:
|
||||
pull_request:
|
||||
branches: [main, develop]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
|
||||
jobs:
|
||||
# Python tests
|
||||
test-python:
|
||||
@@ -36,6 +40,7 @@ jobs:
|
||||
uv pip install -r ../../tests/requirements-test.txt
|
||||
|
||||
- name: Run tests
|
||||
if: matrix.python-version != '3.12'
|
||||
working-directory: apps/backend
|
||||
env:
|
||||
PYTHONPATH: ${{ github.workspace }}/apps/backend
|
||||
@@ -50,7 +55,7 @@ jobs:
|
||||
PYTHONPATH: ${{ github.workspace }}/apps/backend
|
||||
run: |
|
||||
source .venv/bin/activate
|
||||
pytest ../../tests/ -v --cov=. --cov-report=xml --cov-report=term-missing
|
||||
pytest ../../tests/ -v --cov=. --cov-report=xml --cov-report=term-missing --cov-fail-under=20
|
||||
|
||||
- name: Upload coverage reports
|
||||
if: matrix.python-version == '3.12'
|
||||
|
||||
@@ -7,7 +7,7 @@ on:
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
actions: read
|
||||
checks: read
|
||||
|
||||
jobs:
|
||||
update-status:
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
if: github.event.workflow_run.pull_requests[0] != null
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Check all workflows and update label
|
||||
- name: Check all required checks and update label
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
retries: 3
|
||||
@@ -28,13 +28,32 @@ jobs:
|
||||
const headSha = context.payload.workflow_run.head_sha;
|
||||
const triggerWorkflow = context.payload.workflow_run.name;
|
||||
|
||||
// Required workflows configuration
|
||||
const required = [
|
||||
{ name: 'CI', file: 'ci.yml' },
|
||||
{ name: 'Lint', file: 'lint.yml' },
|
||||
{ name: 'Quality Security', file: 'quality-security.yml' },
|
||||
{ name: 'Quality DCO', file: 'quality-dco.yml' },
|
||||
{ name: 'Quality Commit Lint', file: 'quality-commit-lint.yml' }
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// REQUIRED CHECK RUNS - Job-level checks (not workflow-level)
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// Format: "{Workflow Name} / {Job Name} (pull_request)"
|
||||
//
|
||||
// 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: 2025-12-26
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
const requiredChecks = [
|
||||
// CI workflow (ci.yml) - 3 checks
|
||||
'CI / test-frontend (pull_request)',
|
||||
'CI / test-python (3.12) (pull_request)',
|
||||
'CI / test-python (3.13) (pull_request)',
|
||||
// Lint workflow (lint.yml) - 1 check
|
||||
'Lint / python (pull_request)',
|
||||
// Quality Security workflow (quality-security.yml) - 4 checks
|
||||
'Quality Security / CodeQL (javascript-typescript) (pull_request)',
|
||||
'Quality Security / CodeQL (python) (pull_request)',
|
||||
'Quality Security / Python Security (Bandit) (pull_request)',
|
||||
'Quality Security / Security Summary (pull_request)',
|
||||
// Quality DCO workflow (quality-dco.yml) - 1 check
|
||||
'Quality DCO / DCO Check (pull_request)',
|
||||
// Quality Commit Lint workflow (quality-commit-lint.yml) - 1 check
|
||||
'Quality Commit Lint / Conventional Commits (pull_request)'
|
||||
];
|
||||
|
||||
const statusLabels = {
|
||||
@@ -43,64 +62,71 @@ jobs:
|
||||
failed: '❌ Checks Failed'
|
||||
};
|
||||
|
||||
console.log(`::group::PR #${prNumber} - Checking workflow status`);
|
||||
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 all required workflows
|
||||
for (const workflow of required) {
|
||||
try {
|
||||
const { data } = await github.rest.actions.listWorkflowRuns({
|
||||
owner,
|
||||
repo,
|
||||
workflow_id: workflow.file,
|
||||
head_sha: headSha,
|
||||
per_page: 1
|
||||
});
|
||||
// Check each required check
|
||||
for (const checkName of requiredChecks) {
|
||||
const check = allCheckRuns.find(c => c.name === checkName);
|
||||
|
||||
if (!data.workflow_runs.length) {
|
||||
results.push({ name: workflow.name, status: '⏳ Pending', complete: false });
|
||||
allComplete = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
const run = data.workflow_runs[0];
|
||||
|
||||
if (run.status !== 'completed') {
|
||||
results.push({ name: workflow.name, status: '🔄 Running', complete: false });
|
||||
allComplete = false;
|
||||
} else if (run.conclusion === 'success') {
|
||||
results.push({ name: workflow.name, status: '✅ Passed', complete: true });
|
||||
} else if (run.conclusion === 'skipped') {
|
||||
results.push({ name: workflow.name, status: '⏭️ Skipped', complete: true });
|
||||
} else {
|
||||
results.push({ name: workflow.name, status: '❌ Failed', complete: true, failed: true });
|
||||
anyFailed = true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(`::warning::Error checking ${workflow.name}: ${error.message}`);
|
||||
results.push({ name: workflow.name, status: '⚠️ Error', complete: false });
|
||||
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('Workflow Status:');
|
||||
console.log('─'.repeat(40));
|
||||
console.log('');
|
||||
console.log('Check Status:');
|
||||
console.log('─'.repeat(70));
|
||||
for (const r of results) {
|
||||
console.log(` ${r.status.padEnd(12)} ${r.name}`);
|
||||
const shortName = r.name.length > 55 ? r.name.substring(0, 52) + '...' : r.name;
|
||||
console.log(` ${r.status.padEnd(12)} ${shortName}`);
|
||||
}
|
||||
console.log('─'.repeat(40));
|
||||
console.log('─'.repeat(70));
|
||||
console.log('::endgroup::');
|
||||
|
||||
// Only update label if all workflows are complete
|
||||
// Only update label if all required checks are complete
|
||||
if (!allComplete) {
|
||||
console.log('⏳ Not all workflows complete yet - keeping current label');
|
||||
const pending = results.filter(r => !r.complete).length;
|
||||
console.log(`⏳ ${pending}/${requiredChecks.length} checks still pending - keeping current label`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -145,17 +171,25 @@ jobs:
|
||||
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 failing checks`);
|
||||
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 {
|
||||
console.log(`✅ PR #${prNumber} is ready for review`);
|
||||
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: 'Workflow', header: true}, {data: 'Status', header: true}],
|
||||
[{data: 'Check', header: true}, {data: 'Status', header: true}],
|
||||
...results.map(r => [r.name, r.status])
|
||||
]);
|
||||
await core.summary.write();
|
||||
|
||||
@@ -22,6 +22,8 @@ jobs:
|
||||
script: |
|
||||
const pr = context.payload.pull_request;
|
||||
const title = pr.title;
|
||||
// Sanitize title for safe markdown interpolation (prevent injection)
|
||||
const sanitizedTitle = title.replace(/`/g, "'").replace(/\[/g, '\\[').replace(/\]/g, '\\]');
|
||||
|
||||
console.log(`::group::PR #${pr.number} - Validating PR title`);
|
||||
console.log(`Title: ${title}`);
|
||||
@@ -37,9 +39,48 @@ jobs:
|
||||
console.log('::endgroup::');
|
||||
|
||||
if (!isValid) {
|
||||
// Log helpful error message to console (visible in workflow logs)
|
||||
console.log('');
|
||||
console.log('❌ PR title does not follow Conventional Commits format');
|
||||
console.log('');
|
||||
console.log('Expected format: type(scope): description');
|
||||
console.log('');
|
||||
console.log('Valid types: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert');
|
||||
console.log('');
|
||||
console.log('Examples of valid PR titles:');
|
||||
console.log(' ✓ feat(auth): add OAuth2 login support');
|
||||
console.log(' ✓ fix(api): handle null response correctly');
|
||||
console.log(' ✓ docs: update README installation steps');
|
||||
console.log(' ✓ chore: update dependencies');
|
||||
console.log('');
|
||||
console.log(`Your title: "${title}"`);
|
||||
console.log('');
|
||||
console.log('Suggested fix for your title:');
|
||||
// Try to suggest a fix based on the title
|
||||
const lowerTitle = title.toLowerCase();
|
||||
const placeholder = '[add description here]';
|
||||
if (lowerTitle.includes('fix') || lowerTitle.includes('bug')) {
|
||||
const cleaned = title.replace(/^(fix(ed|es|ing)?|bug)[:\s]*/i, '').trim();
|
||||
console.log(` → fix: ${cleaned || placeholder}`);
|
||||
} else if (lowerTitle.includes('add') || lowerTitle.includes('new') || lowerTitle.includes('feature')) {
|
||||
const cleaned = title.replace(/^(add(ed|s|ing)?|new|features?)[:\s]*/i, '').trim();
|
||||
console.log(` → feat: ${cleaned || placeholder}`);
|
||||
} else if (lowerTitle.includes('update') || lowerTitle.includes('change')) {
|
||||
const cleaned = title.replace(/^(update[ds]?|chang(ed|es|ing)?)[:\s]*/i, '').trim();
|
||||
console.log(` → chore: ${cleaned || placeholder}`);
|
||||
} else if (lowerTitle.includes('doc') || lowerTitle.includes('readme')) {
|
||||
const cleaned = title.replace(/^(docs?|readme)[:\s]*/i, '').trim();
|
||||
console.log(` → docs: ${cleaned || placeholder}`);
|
||||
} else {
|
||||
console.log(` → feat: ${title}`);
|
||||
console.log(` → fix: ${title}`);
|
||||
console.log(` → chore: ${title}`);
|
||||
}
|
||||
console.log('');
|
||||
|
||||
let errorMsg = '## ❌ PR Title Validation Failed\n\n';
|
||||
errorMsg += `Your PR title does not follow [Conventional Commits](https://www.conventionalcommits.org/) format:\n\n`;
|
||||
errorMsg += `> \`${title}\`\n\n`;
|
||||
errorMsg += `> \`${sanitizedTitle}\`\n\n`;
|
||||
errorMsg += '### Expected Format\n\n';
|
||||
errorMsg += '```\ntype(scope): description\n```\n\n';
|
||||
errorMsg += '| Type | Description |\n';
|
||||
@@ -69,6 +110,6 @@ jobs:
|
||||
|
||||
core.summary
|
||||
.addHeading('✅ PR Title Valid', 3)
|
||||
.addRaw(`PR title follows Conventional Commits format: \`${title}\``);
|
||||
.addRaw(`PR title follows Conventional Commits format: \`${sanitizedTitle}\``);
|
||||
await core.summary.write();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user