Skip to content

Instantly share code, notes, and snippets.

@pabliqe
Last active April 2, 2026 04:19
Show Gist options
  • Select an option

  • Save pabliqe/875c9973b827d0ab9051b4e03adc5ec2 to your computer and use it in GitHub Desktop.

Select an option

Save pabliqe/875c9973b827d0ab9051b4e03adc5ec2 to your computer and use it in GitHub Desktop.
Update changelog based on git/package version
#!/usr/bin/env node
import { execSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
const repoRoot = process.cwd();
const packageJsonPath = path.join(repoRoot, "package.json");
const changelogPath = path.join(repoRoot, "CHANGELOG.md");
function runGit(command) {
try {
return execSync(command, { cwd: repoRoot, encoding: "utf8" }).trim();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(`Failed to run git command: ${command}\n${message}`);
}
}
function readJson(filePath) {
const raw = fs.readFileSync(filePath, "utf8");
return JSON.parse(raw);
}
function extractVersionsFromChangelog(content) {
const matches = [...content.matchAll(/^## \[([^\]]+)\]/gm)];
return matches.map((match) => match[1]);
}
function getPackageVersionFromCommit(commitHash) {
try {
const pkgRaw = runGit(`git show ${commitHash}:package.json`);
const pkg = JSON.parse(pkgRaw);
return typeof pkg.version === "string" ? pkg.version : null;
} catch {
return null;
}
}
function getVersionTransitions() {
const commitListRaw = runGit("git rev-list --reverse HEAD -- package.json");
if (!commitListRaw) {
return [];
}
const commits = commitListRaw.split("\n").filter(Boolean);
const transitions = [];
let lastVersion = null;
for (const commit of commits) {
const version = getPackageVersionFromCommit(commit);
if (!version || version === lastVersion) {
continue;
}
transitions.push({ version, commit });
lastVersion = version;
}
return transitions;
}
function getCommitsForVersionRange(startCommit, endCommit = "HEAD") {
const logRaw = runGit(`git log --reverse --format="%s|%h" ${startCommit}..${endCommit}`);
const commits = [];
// Include the version-bump commit itself first.
const startSubject = runGit(`git show -s --format=%s ${startCommit}`);
const startShortHash = runGit(`git rev-parse --short ${startCommit}`);
commits.push({ subject: startSubject, shortHash: startShortHash });
if (!logRaw) {
return commits;
}
for (const line of logRaw.split("\n")) {
if (!line) {
continue;
}
const separatorIndex = line.lastIndexOf("|");
if (separatorIndex === -1) {
continue;
}
const subject = line.slice(0, separatorIndex).trim();
const shortHash = line.slice(separatorIndex + 1).trim();
if (!subject || !shortHash) {
continue;
}
commits.push({ subject, shortHash });
}
return commits;
}
const SKIP_PATTERNS = [/\[skip changelog\]/i, /\[no changelog\]/i, /\[changelog skip\]/i];
const skipListPath = path.join(repoRoot, ".changelog-skip");
function loadSkipList() {
if (!fs.existsSync(skipListPath)) {
return { hashes: new Set(), phrases: [] };
}
const lines = fs.readFileSync(skipListPath, "utf8").split("\n");
const hashes = new Set();
const phrases = [];
for (const raw of lines) {
const line = raw.replace(/#.*$/, "").trim();
if (!line) {
continue;
}
if (line.startsWith("~")) {
const phrase = line.slice(1).trim();
if (phrase) {
phrases.push(phrase.toLowerCase());
}
} else {
hashes.add(line);
}
}
return { hashes, phrases };
}
function shouldSkipCommit(subject, phrases) {
if (SKIP_PATTERNS.some((pattern) => pattern.test(subject))) {
return true;
}
const lower = subject.toLowerCase();
return phrases.some((phrase) => lower.includes(phrase));
}
function buildVersionSection(version, commits, skipList) {
const lines = [`## [${version}]`];
for (const commit of commits) {
if (shouldSkipCommit(commit.subject, skipList.phrases) || skipList.hashes.has(commit.shortHash)) {
continue;
}
lines.push(`- ${commit.subject} (\`${commit.shortHash}\`)`);
}
return `${lines.join("\n")}\n`;
}
function upsertChangelog(version, sectionText) {
if (!fs.existsSync(changelogPath)) {
const initial = `# Changelog\n\n${sectionText}`;
fs.writeFileSync(changelogPath, initial, "utf8");
return { created: true, updated: false };
}
const existing = fs.readFileSync(changelogPath, "utf8");
const existingVersions = extractVersionsFromChangelog(existing);
if (existingVersions.includes(version)) {
return { created: false, updated: false, skipped: true };
}
if (existing.startsWith("# Changelog")) {
const updated = existing.replace("# Changelog\n\n", `# Changelog\n\n${sectionText}\n`);
fs.writeFileSync(changelogPath, updated, "utf8");
} else {
const updated = `# Changelog\n\n${sectionText}\n${existing}`;
fs.writeFileSync(changelogPath, updated, "utf8");
}
return { created: false, updated: true, skipped: false };
}
function main() {
const pkg = readJson(packageJsonPath);
const currentVersion = pkg.version;
if (typeof currentVersion !== "string" || !currentVersion) {
throw new Error("package.json does not contain a valid version.");
}
const transitions = getVersionTransitions();
const currentTransition = transitions.find((entry) => entry.version === currentVersion);
if (!currentTransition) {
throw new Error(
`Could not find a package.json version transition commit for version ${currentVersion}.`
);
}
const skipList = loadSkipList();
// If the changelog is missing, rebuild it from all historical versions.
if (!fs.existsSync(changelogPath)) {
const sections = [];
for (let i = 0; i < transitions.length; i++) {
const { version, commit } = transitions[i];
const endCommit = i + 1 < transitions.length ? transitions[i + 1].commit : "HEAD";
const commits = getCommitsForVersionRange(commit, endCommit);
if (commits.length > 0) {
sections.unshift(buildVersionSection(version, commits, skipList));
}
}
const content = `# Changelog\n\n${sections.join("\n")}`;
fs.writeFileSync(changelogPath, content, "utf8");
console.log(`Created CHANGELOG.md with ${transitions.length} version(s).`);
return;
}
const commits = getCommitsForVersionRange(currentTransition.commit);
if (commits.length === 0) {
throw new Error(`No commits found for version ${currentVersion}.`);
}
const section = buildVersionSection(currentVersion, commits, skipList);
const result = upsertChangelog(currentVersion, section);
if (result.skipped) {
console.log(`CHANGELOG.md already contains version ${currentVersion}. Nothing changed.`);
return;
}
console.log(`Prepended version ${currentVersion} to CHANGELOG.md.`);
}
main();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment