0d3cb498a3
Deploy local.promptfoo.app / Deploy to Cloudflare Pages (push) Waiting to run
Test and Publish Multi-arch Docker Image / test (push) Waiting to run
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-amd64 platform:linux/amd64 runner:ubuntu-latest]) (push) Blocked by required conditions
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-arm64 platform:linux/arm64 runner:ubuntu-24.04-arm]) (push) Blocked by required conditions
Test and Publish Multi-arch Docker Image / merge-docker-digests (push) Blocked by required conditions
Test and Publish Multi-arch Docker Image / Attest Multi-arch Image (push) Blocked by required conditions
Validate Renovate Config / Validate Renovate Configuration (push) Waiting to run
CI / Shell Format Check (push) Has been cancelled
CI / Check Ruby (3.4) (push) Has been cancelled
CI / CI Config (push) Has been cancelled
CI / Test on Node ${{ matrix.node }} and ${{ matrix.os }}${{ matrix.shard && format(' (shard {0}/3)', matrix.shard) || '' }} (push) Has been cancelled
CI / Build on Node ${{ matrix.node }} (push) Has been cancelled
CI / Style Check (push) Has been cancelled
CI / Generate Assets (push) Has been cancelled
CI / Check Python (3.14) (push) Has been cancelled
CI / Check Python (3.9) (push) Has been cancelled
CI / Build Docs (push) Has been cancelled
CI / Code Scan Action (push) Has been cancelled
CI / Site tests (push) Has been cancelled
CI / webui tests (push) Has been cancelled
CI / Run Integration Tests (push) Has been cancelled
CI / Run Smoke Tests (push) Has been cancelled
CI / Go Tests (push) Has been cancelled
CI / Share Test (push) Has been cancelled
CI / Redteam (Production API) (push) Has been cancelled
CI / Redteam (Staging API) (push) Has been cancelled
CI / GitHub Actions Lint (push) Has been cancelled
CI / Check Ruby (3.0) (push) Has been cancelled
release-please / release-please (push) Has been cancelled
release-please / build (push) Has been cancelled
release-please / publish-npm (push) Has been cancelled
release-please / publish-npm-backfill (push) Has been cancelled
release-please / docker (push) Has been cancelled
release-please / publish-code-scan-action (push) Has been cancelled
release-please / attest-code-scan-action (push) Has been cancelled
313 lines
9.1 KiB
JavaScript
313 lines
9.1 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Validates CHANGELOG.md entries for PR requirements:
|
|
* - All entries must have PR numbers (#1234)
|
|
* - No commit hashes used instead of PR numbers
|
|
* - All entries must be in Unreleased section
|
|
*
|
|
* Used by GitHub Actions workflow for changelog validation
|
|
*/
|
|
|
|
const { execSync } = require('child_process');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
|
|
/**
|
|
* @typedef {Object} ValidationResult
|
|
* @property {boolean} valid - Whether validation passed
|
|
* @property {string} [error] - Error type if validation failed
|
|
* @property {string} [message] - Human-readable error message
|
|
* @property {string[]} [entries] - Problematic entries
|
|
*/
|
|
|
|
/**
|
|
* Check if entries are missing PR numbers
|
|
* @param {string[]} entries - Array of changelog entry lines
|
|
* @returns {string[]} Entries missing PR numbers
|
|
*/
|
|
function findEntriesMissingPrNumber(entries) {
|
|
const prNumberPattern = /\(#\d+\)/;
|
|
return entries.filter((entry) => !prNumberPattern.test(entry));
|
|
}
|
|
|
|
/**
|
|
* Check if entries use commit hashes instead of PR numbers
|
|
* @param {string[]} entries - Array of changelog entry lines
|
|
* @returns {string[]} Entries with commit hashes
|
|
*/
|
|
function findEntriesWithCommitHash(entries) {
|
|
// Match (abc1234) pattern - a hash in parentheses without # prefix
|
|
const commitHashPattern = /\([a-f0-9]{7,40}\)/;
|
|
// Valid PR number pattern
|
|
const prNumberPattern = /\(#\d+\)/;
|
|
|
|
return entries.filter((entry) => {
|
|
const hasCommitHash = commitHashPattern.test(entry);
|
|
if (!hasCommitHash) {
|
|
return false;
|
|
}
|
|
|
|
// If entry has a valid PR number, check if the hash pattern is separate from it
|
|
// e.g., "feature (abc1234) (#1234)" has both - that's still wrong
|
|
// Remove valid PR references and check if commit hash pattern still exists
|
|
const withoutPrRefs = entry.replace(prNumberPattern, '');
|
|
return commitHashPattern.test(withoutPrRefs);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Parse git diff to extract added changelog entries
|
|
* @param {string} diff - Git diff output
|
|
* @returns {string[]} Added entry lines (without leading +)
|
|
*/
|
|
function parseAddedEntries(diff) {
|
|
const lines = diff.split('\n');
|
|
const entries = [];
|
|
|
|
for (const line of lines) {
|
|
// Match lines that start with +- (added entry lines)
|
|
if (line.match(/^\+- /)) {
|
|
entries.push(line.substring(1)); // Remove leading +
|
|
}
|
|
}
|
|
|
|
return entries;
|
|
}
|
|
|
|
/**
|
|
* Find line number where Unreleased section ends
|
|
* @param {string} changelogContent - Full changelog content
|
|
* @returns {number} Line number of first versioned section header
|
|
*/
|
|
function findUnreleasedEnd(changelogContent) {
|
|
const lines = changelogContent.split('\n');
|
|
const versionPattern = /^## \[\d+\.\d+\.\d+\]/;
|
|
|
|
for (let i = 0; i < lines.length; i++) {
|
|
if (versionPattern.test(lines[i])) {
|
|
return i + 1; // Convert to 1-based line number
|
|
}
|
|
}
|
|
|
|
return 999999; // No versioned sections yet
|
|
}
|
|
|
|
/**
|
|
* Parse diff hunks to find entries added in versioned sections
|
|
* @param {string} diff - Git diff output
|
|
* @param {number} unreleasedEnd - Line number where versioned sections begin
|
|
* @returns {Array<{line: number, content: string}>} Entries in versioned sections
|
|
*/
|
|
function findEntriesInVersionedSections(diff, unreleasedEnd) {
|
|
const lines = diff.split('\n');
|
|
const entriesInVersioned = [];
|
|
let currentLine = 0;
|
|
|
|
for (const line of lines) {
|
|
// Parse hunk header: @@ -old_start,old_count +new_start,new_count @@
|
|
const hunkMatch = line.match(/^@@ .* \+(\d+)/);
|
|
if (hunkMatch) {
|
|
currentLine = parseInt(hunkMatch[1], 10) - 1;
|
|
continue;
|
|
}
|
|
|
|
// Skip removed lines
|
|
if (line.startsWith('-')) {
|
|
continue;
|
|
}
|
|
|
|
// Track added lines
|
|
if (line.startsWith('+')) {
|
|
currentLine++;
|
|
// Check if this is a content line (starts with +- )
|
|
if (line.match(/^\+- /) && currentLine >= unreleasedEnd) {
|
|
entriesInVersioned.push({
|
|
line: currentLine,
|
|
content: line.substring(1), // Remove leading +
|
|
});
|
|
}
|
|
continue;
|
|
}
|
|
|
|
// Context lines
|
|
currentLine++;
|
|
}
|
|
|
|
return entriesInVersioned;
|
|
}
|
|
|
|
/**
|
|
* Validate changelog entries in a git diff
|
|
* @param {Object} options - Validation options
|
|
* @param {string} options.diff - Git diff of CHANGELOG.md
|
|
* @param {string} options.changelogContent - Current changelog file content
|
|
* @param {string} options.branchName - Current branch name
|
|
* @param {number} options.prNumber - PR number for error messages
|
|
* @returns {ValidationResult} Validation result
|
|
*/
|
|
function validateChangelogDiff({ diff, changelogContent, branchName, prNumber }) {
|
|
// Skip validation for version bump PRs
|
|
if (branchName && branchName.startsWith('chore/bump-version-')) {
|
|
return { valid: true };
|
|
}
|
|
|
|
const addedEntries = parseAddedEntries(diff);
|
|
|
|
// No entries to validate
|
|
if (addedEntries.length === 0) {
|
|
return { valid: true };
|
|
}
|
|
|
|
// Rule 1: Check for PR number requirement
|
|
const missingPrNumber = findEntriesMissingPrNumber(addedEntries);
|
|
if (missingPrNumber.length > 0) {
|
|
return {
|
|
valid: false,
|
|
error: 'missing_pr_number',
|
|
message: `Changelog entry missing PR number
|
|
|
|
Every changelog entry must include a PR number in (#1234) format.
|
|
|
|
Expected format: - feat(scope): description (#${prNumber || '1234'})
|
|
|
|
Entries missing PR numbers:`,
|
|
entries: missingPrNumber,
|
|
};
|
|
}
|
|
|
|
// Rule 2: Check for commit hashes instead of PR numbers
|
|
const withCommitHash = findEntriesWithCommitHash(addedEntries);
|
|
if (withCommitHash.length > 0) {
|
|
return {
|
|
valid: false,
|
|
error: 'commit_hash_instead_of_pr',
|
|
message: `Commit hash detected instead of PR number
|
|
|
|
Use PR numbers (#1234) instead of commit hashes (abc1234).
|
|
|
|
Entries with commit hashes:`,
|
|
entries: withCommitHash,
|
|
};
|
|
}
|
|
|
|
// Rule 3: Check entries are in Unreleased section
|
|
const unreleasedEnd = findUnreleasedEnd(changelogContent);
|
|
const entriesInVersioned = findEntriesInVersionedSections(diff, unreleasedEnd);
|
|
|
|
if (entriesInVersioned.length > 0) {
|
|
return {
|
|
valid: false,
|
|
error: 'entries_in_versioned_section',
|
|
message: `Changelog entries must be in the Unreleased section
|
|
|
|
The following entries were added to versioned sections:`,
|
|
entries: entriesInVersioned.map((e) => `Line ${e.line}: ${e.content}`),
|
|
footer: `
|
|
Add your entries under '## [Unreleased]', not in versioned sections.
|
|
Versioned sections are only updated during release.`,
|
|
};
|
|
}
|
|
|
|
return { valid: true };
|
|
}
|
|
|
|
/**
|
|
* Run validation from command line
|
|
*/
|
|
function main() {
|
|
const baseRef = process.env.BASE_REF || 'main';
|
|
const prNumber = process.env.PR_NUMBER || '';
|
|
const branchName = process.env.BRANCH_NAME || '';
|
|
|
|
try {
|
|
// Fetch base branch
|
|
execSync(`git fetch origin "${baseRef}"`, { stdio: 'pipe' });
|
|
|
|
// Check if CHANGELOG.md was modified
|
|
const modifiedFiles = execSync(`git diff --name-only origin/${baseRef}...HEAD`, {
|
|
encoding: 'utf8',
|
|
});
|
|
|
|
const changelogModified = modifiedFiles.split('\n').includes('CHANGELOG.md');
|
|
|
|
if (!changelogModified) {
|
|
// Check for bypass labels
|
|
const hasNoChangelogLabel = process.env.HAS_NO_CHANGELOG_LABEL === 'true';
|
|
const hasDependenciesLabel = process.env.HAS_DEPENDENCIES_LABEL === 'true';
|
|
|
|
if (hasNoChangelogLabel) {
|
|
console.log("PR has 'no-changelog' label - bypassing check");
|
|
process.exit(0);
|
|
}
|
|
|
|
if (hasDependenciesLabel) {
|
|
console.log("PR has 'dependencies' label - bypassing check");
|
|
process.exit(0);
|
|
}
|
|
|
|
console.log('Changelog update required');
|
|
console.log('');
|
|
console.log(
|
|
"PRs that modify source code must update CHANGELOG.md under the 'Unreleased' section.",
|
|
);
|
|
console.log('');
|
|
console.log('This ensures complete traceability of all changes to the codebase.');
|
|
console.log('');
|
|
console.log('To bypass this check, add one of these labels:');
|
|
console.log(" - 'no-changelog' (exceptional cases only)");
|
|
console.log(" - 'dependencies' (automated dependency updates)");
|
|
process.exit(1);
|
|
}
|
|
|
|
console.log('CHANGELOG.md has been updated');
|
|
|
|
// Get diff and changelog content
|
|
const diff = execSync(`git diff origin/${baseRef}...HEAD -- CHANGELOG.md`, {
|
|
encoding: 'utf8',
|
|
});
|
|
|
|
const changelogPath = path.join(process.cwd(), 'CHANGELOG.md');
|
|
const changelogContent = fs.readFileSync(changelogPath, 'utf8');
|
|
|
|
// Validate
|
|
const result = validateChangelogDiff({
|
|
diff,
|
|
changelogContent,
|
|
branchName,
|
|
prNumber,
|
|
});
|
|
|
|
if (!result.valid) {
|
|
console.log(result.message);
|
|
if (result.entries) {
|
|
result.entries.forEach((entry) => console.log(` ${entry}`));
|
|
}
|
|
if (result.footer) {
|
|
console.log(result.footer);
|
|
}
|
|
process.exit(1);
|
|
}
|
|
|
|
console.log('Changelog validation passed');
|
|
process.exit(0);
|
|
} catch (error) {
|
|
console.error('Error during changelog validation:', error.message);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
// Export for testing
|
|
module.exports = {
|
|
findEntriesMissingPrNumber,
|
|
findEntriesWithCommitHash,
|
|
parseAddedEntries,
|
|
findUnreleasedEnd,
|
|
findEntriesInVersionedSections,
|
|
validateChangelogDiff,
|
|
};
|
|
|
|
// Run if called directly
|
|
if (require.main === module) {
|
|
main();
|
|
}
|