Files
promptfoo--promptfoo/scripts/validate-changelog.cjs
T
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:24:08 +08:00

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();
}