@oorabona/release-it-preset 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +833 -0
  3. package/bin/cli.js +180 -0
  4. package/bin/run-script.js +72 -0
  5. package/config/changelog-only.js +41 -0
  6. package/config/default.js +54 -0
  7. package/config/helpers.js +52 -0
  8. package/config/hotfix.js +51 -0
  9. package/config/manual-changelog.js +64 -0
  10. package/config/no-changelog.js +40 -0
  11. package/config/republish.js +60 -0
  12. package/config/retry-publish.js +40 -0
  13. package/dist/scripts/check-config.js +285 -0
  14. package/dist/scripts/check-pr-status.js +164 -0
  15. package/dist/scripts/extract-changelog.js +66 -0
  16. package/dist/scripts/init-project.js +191 -0
  17. package/dist/scripts/lib/commit-parser.js +67 -0
  18. package/dist/scripts/lib/git-utils.js +33 -0
  19. package/dist/scripts/lib/semver-utils.js +26 -0
  20. package/dist/scripts/lib/string-utils.js +12 -0
  21. package/dist/scripts/populate-unreleased-changelog.js +236 -0
  22. package/dist/scripts/republish-changelog.js +187 -0
  23. package/dist/scripts/retry-publish.js +98 -0
  24. package/dist/scripts/validate-release.js +288 -0
  25. package/dist/types/check-config.d.ts +65 -0
  26. package/dist/types/check-pr-status.d.ts +51 -0
  27. package/dist/types/extract-changelog.d.ts +23 -0
  28. package/dist/types/init-project.d.ts +37 -0
  29. package/dist/types/lib/commit-parser.d.ts +44 -0
  30. package/dist/types/lib/git-utils.d.ts +20 -0
  31. package/dist/types/lib/semver-utils.d.ts +18 -0
  32. package/dist/types/lib/string-utils.d.ts +10 -0
  33. package/dist/types/populate-unreleased-changelog.d.ts +56 -0
  34. package/dist/types/republish-changelog.d.ts +39 -0
  35. package/dist/types/retry-publish.d.ts +33 -0
  36. package/dist/types/validate-release.d.ts +43 -0
  37. package/package.json +93 -0
@@ -0,0 +1,191 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * Initialize a project with release-it-preset
4
+ *
5
+ * This script:
6
+ * - Creates CHANGELOG.md with Keep a Changelog template
7
+ * - Creates .release-it.json with extends configuration
8
+ * - Optionally adds scripts to package.json
9
+ *
10
+ * Usage:
11
+ * tsx init-project.ts [--yes]
12
+ *
13
+ * Options:
14
+ * --yes Skip prompts and use defaults
15
+ */
16
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
17
+ import { createInterface } from 'node:readline';
18
+ const CHANGELOG_TEMPLATE = `# Changelog
19
+
20
+ All notable changes to this project will be documented in this file.
21
+
22
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
23
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
24
+
25
+ ## [Unreleased]
26
+
27
+ ### Added
28
+ - Initial release
29
+ `;
30
+ const RELEASE_IT_CONFIG = `{
31
+ "extends": "@oorabona/release-it-preset/config/default"
32
+ }
33
+ `;
34
+ const SUGGESTED_SCRIPTS = {
35
+ 'release': 'release-it-preset default',
36
+ 'release:hotfix': 'release-it-preset hotfix',
37
+ 'release:dry': 'release-it-preset default --dry-run',
38
+ 'changelog:update': 'release-it-preset update',
39
+ };
40
+ export function parseArgs(args) {
41
+ /* c8 ignore next */
42
+ const argv = args || process.argv.slice(2);
43
+ return {
44
+ yes: argv.includes('--yes') || argv.includes('-y'),
45
+ };
46
+ }
47
+ export async function createChangelog(options, deps) {
48
+ const path = 'CHANGELOG.md';
49
+ if (deps.existsSync(path)) {
50
+ deps.warn(`⚠️ ${path} already exists`);
51
+ if (!options.yes) {
52
+ const overwrite = await deps.prompt('Overwrite it?');
53
+ if (!overwrite) {
54
+ deps.log(`ℹ️ Skipping ${path}`);
55
+ return false;
56
+ }
57
+ }
58
+ else {
59
+ deps.log(`ℹ️ Skipping ${path} (--yes mode, not overwriting existing files)`);
60
+ return false;
61
+ }
62
+ }
63
+ deps.writeFileSync(path, CHANGELOG_TEMPLATE);
64
+ deps.log(`✅ Created ${path}`);
65
+ return true;
66
+ }
67
+ export async function createReleaseItConfig(options, deps) {
68
+ const path = '.release-it.json';
69
+ if (deps.existsSync(path)) {
70
+ deps.warn(`⚠️ ${path} already exists`);
71
+ if (!options.yes) {
72
+ const overwrite = await deps.prompt('Overwrite it?');
73
+ if (!overwrite) {
74
+ deps.log(`ℹ️ Skipping ${path}`);
75
+ return false;
76
+ }
77
+ }
78
+ else {
79
+ deps.log(`ℹ️ Skipping ${path} (--yes mode, not overwriting existing files)`);
80
+ return false;
81
+ }
82
+ }
83
+ deps.writeFileSync(path, RELEASE_IT_CONFIG);
84
+ deps.log(`✅ Created ${path}`);
85
+ return true;
86
+ }
87
+ export async function updatePackageJson(options, deps) {
88
+ const path = 'package.json';
89
+ if (!deps.existsSync(path)) {
90
+ deps.warn(`⚠️ ${path} not found, skipping script addition`);
91
+ return false;
92
+ }
93
+ try {
94
+ const packageJson = JSON.parse(deps.readFileSync(path, 'utf8'));
95
+ if (!packageJson.scripts) {
96
+ packageJson.scripts = {};
97
+ }
98
+ const scriptsToAdd = {};
99
+ let hasConflicts = false;
100
+ for (const [name, command] of Object.entries(SUGGESTED_SCRIPTS)) {
101
+ if (packageJson.scripts[name]) {
102
+ deps.warn(`⚠️ Script "${name}" already exists in package.json`);
103
+ hasConflicts = true;
104
+ }
105
+ else {
106
+ scriptsToAdd[name] = command;
107
+ }
108
+ }
109
+ if (Object.keys(scriptsToAdd).length === 0) {
110
+ deps.log(`ℹ️ All suggested scripts already exist in package.json`);
111
+ return false;
112
+ }
113
+ if (!options.yes) {
114
+ deps.log(`\n📝 Suggested scripts to add to package.json:`);
115
+ for (const [name, command] of Object.entries(scriptsToAdd)) {
116
+ deps.log(` "${name}": "${command}"`);
117
+ }
118
+ const addScripts = await deps.prompt('\nAdd these scripts to package.json?');
119
+ if (!addScripts) {
120
+ deps.log(`ℹ️ Skipping package.json scripts`);
121
+ return false;
122
+ }
123
+ }
124
+ Object.assign(packageJson.scripts, scriptsToAdd);
125
+ deps.writeFileSync(path, JSON.stringify(packageJson, null, 2) + '\n');
126
+ deps.log(`✅ Updated ${path} with ${Object.keys(scriptsToAdd).length} script(s)`);
127
+ return true;
128
+ }
129
+ catch (error) {
130
+ deps.warn(`❌ Failed to update ${path}: ${error}`);
131
+ return false;
132
+ }
133
+ }
134
+ export async function initProject(options, deps) {
135
+ deps.log('🚀 Initializing project with release-it-preset\n');
136
+ if (options.yes) {
137
+ deps.log('ℹ️ Running in --yes mode (non-interactive)\n');
138
+ }
139
+ const results = {
140
+ changelog: await createChangelog(options, deps),
141
+ releaseIt: await createReleaseItConfig(options, deps),
142
+ packageJson: await updatePackageJson(options, deps),
143
+ };
144
+ deps.log('\n📊 Summary:');
145
+ deps.log(` CHANGELOG.md: ${results.changelog ? '✅ Created' : '⏭️ Skipped'}`);
146
+ deps.log(` .release-it.json: ${results.releaseIt ? '✅ Created' : '⏭️ Skipped'}`);
147
+ deps.log(` package.json: ${results.packageJson ? '✅ Updated' : '⏭️ Skipped'}`);
148
+ const anyCreated = Object.values(results).some((v) => v);
149
+ if (anyCreated) {
150
+ deps.log('\n🎉 Initialization complete!');
151
+ deps.log('\nNext steps:');
152
+ deps.log(' 1. Review the generated files');
153
+ deps.log(' 2. Update CHANGELOG.md [Unreleased] section');
154
+ deps.log(' 3. Run: pnpm release-it-preset default --dry-run');
155
+ }
156
+ else {
157
+ deps.log('\n✨ All files already exist, nothing to do!');
158
+ }
159
+ return results;
160
+ }
161
+ /**
162
+ * CLI entry point - only runs when script is executed directly
163
+ */
164
+ /* c8 ignore start */
165
+ if (import.meta.url === `file://${process.argv[1]}`) {
166
+ async function realPrompt(question) {
167
+ const rl = createInterface({
168
+ input: process.stdin,
169
+ output: process.stdout,
170
+ });
171
+ return new Promise((resolve) => {
172
+ rl.question(`${question} (y/N): `, (answer) => {
173
+ rl.close();
174
+ resolve(answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes');
175
+ });
176
+ });
177
+ }
178
+ const options = parseArgs();
179
+ initProject(options, {
180
+ existsSync,
181
+ readFileSync,
182
+ writeFileSync,
183
+ prompt: realPrompt,
184
+ log: console.log,
185
+ warn: console.warn,
186
+ }).catch((error) => {
187
+ console.error('❌ Initialization failed:', error);
188
+ process.exit(1);
189
+ });
190
+ }
191
+ /* c8 ignore end */
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Conventional commit parsing utilities
3
+ */
4
+ /**
5
+ * Regex pattern for matching conventional commits
6
+ * Matches: type(scope)?: description
7
+ * Example: feat(api): add new endpoint
8
+ */
9
+ export const CONVENTIONAL_COMMIT_REGEX = /^(\w+)(?:\(([^)]+)\))?(!)?\s*:\s*(.+)/m;
10
+ /**
11
+ * Strict conventional commit types as defined by Conventional Commits specification
12
+ * Used for strict validation in PR checks
13
+ */
14
+ export const STRICT_CONVENTIONAL_TYPES = [
15
+ 'feat',
16
+ 'fix',
17
+ 'docs',
18
+ 'style',
19
+ 'refactor',
20
+ 'perf',
21
+ 'test',
22
+ 'chore',
23
+ 'build',
24
+ 'ci',
25
+ 'revert',
26
+ ];
27
+ /**
28
+ * Regex for strict conventional commit validation (only approved types)
29
+ */
30
+ export const STRICT_CONVENTIONAL_COMMIT_REGEX = new RegExp(`^(${STRICT_CONVENTIONAL_TYPES.join('|')})(\\(.+\\))?:\\s.+`, 'i');
31
+ /**
32
+ * Check if a commit message matches conventional commit format (lenient)
33
+ *
34
+ * @param message Commit message to check
35
+ * @returns true if message matches conventional format
36
+ */
37
+ export function isConventionalCommit(message) {
38
+ return CONVENTIONAL_COMMIT_REGEX.test(message);
39
+ }
40
+ /**
41
+ * Check if a commit message matches strict conventional commit format
42
+ *
43
+ * @param message Commit message to check
44
+ * @returns true if message matches strict format
45
+ */
46
+ export function isStrictConventionalCommit(message) {
47
+ return STRICT_CONVENTIONAL_COMMIT_REGEX.test(message);
48
+ }
49
+ /**
50
+ * Extract conventional commit parts from a message
51
+ *
52
+ * @param message Commit message
53
+ * @returns Parsed parts or null if not conventional
54
+ */
55
+ export function parseConventionalCommit(message) {
56
+ const match = message.match(CONVENTIONAL_COMMIT_REGEX);
57
+ if (!match) {
58
+ return null;
59
+ }
60
+ const [, type, scope, breaking, description] = match;
61
+ return {
62
+ type: type.trim(),
63
+ scope: scope?.trim(),
64
+ breaking: Boolean(breaking),
65
+ description: description.trim(),
66
+ };
67
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Git utility functions shared across scripts
3
+ */
4
+ /**
5
+ * Get GitHub repository URL for commit/tag links
6
+ *
7
+ * Priority:
8
+ * 1. GITHUB_REPOSITORY environment variable (format: owner/repo)
9
+ * 2. Git remote URL (extracted from git config)
10
+ *
11
+ * @param deps Dependencies for git operations
12
+ * @returns Repository URL or empty string if not determinable
13
+ */
14
+ export function getGitHubRepoUrl(deps) {
15
+ const githubRepo = deps.getEnv('GITHUB_REPOSITORY');
16
+ if (githubRepo) {
17
+ return `https://github.com/${githubRepo}`;
18
+ }
19
+ try {
20
+ const remote = deps.getEnv('GIT_REMOTE') || 'origin';
21
+ const remoteUrl = deps.execSync(`git config --get remote.${remote}.url`, { encoding: 'utf8' }).trim();
22
+ return remoteUrl
23
+ .replace(/^git@github\.com:/, 'https://github.com/')
24
+ .replace(/\.git$/, '');
25
+ }
26
+ catch (error) {
27
+ if (deps.warn) {
28
+ deps.warn('⚠️ Could not determine repository URL. Links will not be generated.');
29
+ deps.warn(' Set GITHUB_REPOSITORY environment variable (e.g., owner/repo)');
30
+ }
31
+ return '';
32
+ }
33
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Semantic versioning utility functions
3
+ */
4
+ /**
5
+ * Validate if a string is a valid semantic version
6
+ *
7
+ * @param version Version string to validate (can have optional 'v' prefix)
8
+ * @returns true if valid semver, false otherwise
9
+ */
10
+ export function isValidSemver(version) {
11
+ const semverRegex = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/;
12
+ return semverRegex.test(version);
13
+ }
14
+ /**
15
+ * Validate and normalize a semantic version string
16
+ *
17
+ * @param version Version string to validate
18
+ * @throws Error if version is not valid semver
19
+ * @returns Normalized version without 'v' prefix
20
+ */
21
+ export function validateAndNormalizeSemver(version) {
22
+ if (!isValidSemver(version)) {
23
+ throw new Error(`Invalid semantic version: "${version}". Expected format: [v]MAJOR.MINOR.PATCH[-prerelease][+buildmetadata]`);
24
+ }
25
+ return version.replace(/^v/, '');
26
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * String utility functions shared across scripts
3
+ */
4
+ /**
5
+ * Escape special regex characters in a string
6
+ *
7
+ * @param input String to escape
8
+ * @returns Escaped string safe for use in RegExp
9
+ */
10
+ export function escapeRegExp(input) {
11
+ return input.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&');
12
+ }
@@ -0,0 +1,236 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * Populate [Unreleased] section with commits since the last tag
4
+ *
5
+ * This script:
6
+ * - Extracts commits since the last git tag
7
+ * - Parses conventional commit messages
8
+ * - Groups commits by type (Added, Fixed, Changed, etc.)
9
+ * - Updates the [Unreleased] section in CHANGELOG.md
10
+ * - Generates commit links using the repository URL
11
+ *
12
+ * Usage:
13
+ * tsx populate-unreleased-changelog.ts
14
+ *
15
+ * Environment variables:
16
+ * CHANGELOG_FILE - Path to changelog file (default: CHANGELOG.md)
17
+ * GITHUB_REPOSITORY - GitHub repo (owner/repo) for commit links
18
+ * GIT_REMOTE - Git remote name (default: origin)
19
+ */
20
+ import { execSync } from 'node:child_process';
21
+ import { readFileSync, writeFileSync } from 'node:fs';
22
+ import { getGitHubRepoUrl } from './lib/git-utils.js';
23
+ import { CONVENTIONAL_COMMIT_REGEX } from './lib/commit-parser.js';
24
+ /**
25
+ * Extract all conventional commit patterns from a commit body
26
+ */
27
+ export function extractConventionalCommitParts(commitBody, sha) {
28
+ const parts = [];
29
+ const conventionalCommitRegex = new RegExp(CONVENTIONAL_COMMIT_REGEX.source, 'gm');
30
+ let match;
31
+ while ((match = conventionalCommitRegex.exec(commitBody)) !== null) {
32
+ const [, type, scope, breaking, description] = match;
33
+ if (type && description && description.trim()) {
34
+ const cleanDescription = description.trim().replace(/\s+/g, ' ');
35
+ parts.push({
36
+ type: type.trim(),
37
+ scope: scope?.trim(),
38
+ description: cleanDescription,
39
+ sha,
40
+ breaking: Boolean(breaking),
41
+ });
42
+ }
43
+ }
44
+ return parts;
45
+ }
46
+ /**
47
+ * Normalize commit types to standard changelog categories
48
+ */
49
+ export function normalizeCommitType(type) {
50
+ const typeMap = {
51
+ feat: '### Added',
52
+ feature: '### Added',
53
+ add: '### Added',
54
+ fix: '### Fixed',
55
+ bugfix: '### Fixed',
56
+ security: '### Security',
57
+ perf: '### Changed',
58
+ refactor: '### Changed',
59
+ style: '### Changed',
60
+ docs: '### Changed',
61
+ test: '### Changed',
62
+ chore: '### Changed',
63
+ build: '### Changed',
64
+ deps: '### Changed',
65
+ dependency: '### Changed',
66
+ dependencies: '### Changed',
67
+ revert: '### Changed',
68
+ remove: '### Removed',
69
+ removed: '### Removed',
70
+ delete: '### Removed',
71
+ deleted: '### Removed',
72
+ ci: false,
73
+ release: false,
74
+ hotfix: false,
75
+ misc: '### Changed',
76
+ };
77
+ const result = typeMap[type.toLowerCase()];
78
+ return result !== undefined ? result : '### Changed';
79
+ }
80
+ /**
81
+ * Parse git log output and extract all conventional commit parts
82
+ */
83
+ export function parseCommitsWithMultiplePrefixes(gitOutput, repoUrl) {
84
+ if (!gitOutput)
85
+ return '';
86
+ const commitEntries = gitOutput.split('|||END|||').filter((entry) => entry.trim());
87
+ const allParts = [];
88
+ for (const entry of commitEntries) {
89
+ const [sha, ...bodyParts] = entry.split('|');
90
+ const body = bodyParts.join('|').trim();
91
+ if (/\[skip-changelog\]/i.test(body)) {
92
+ continue;
93
+ }
94
+ if (sha && body) {
95
+ const shortSha = sha.trim().substring(0, 7);
96
+ const parts = extractConventionalCommitParts(body, shortSha);
97
+ if (parts.length === 0) {
98
+ const firstLine = body.split('\n')[0].trim();
99
+ if (firstLine) {
100
+ allParts.push({
101
+ type: 'misc',
102
+ description: firstLine,
103
+ sha: shortSha,
104
+ });
105
+ }
106
+ }
107
+ else {
108
+ allParts.push(...parts);
109
+ }
110
+ }
111
+ }
112
+ const groupedParts = {};
113
+ const breakingChanges = [];
114
+ for (const part of allParts) {
115
+ // Collect breaking changes separately
116
+ if (part.breaking) {
117
+ breakingChanges.push(part);
118
+ }
119
+ const sectionName = normalizeCommitType(part.type);
120
+ if (sectionName === false) {
121
+ continue;
122
+ }
123
+ if (!groupedParts[sectionName]) {
124
+ groupedParts[sectionName] = [];
125
+ }
126
+ groupedParts[sectionName].push(part);
127
+ }
128
+ const sections = [];
129
+ const sectionOrder = ['### Added', '### Fixed', '### Changed', '### Removed', '### Security'];
130
+ // Add BREAKING CHANGES section first if there are any
131
+ if (breakingChanges.length > 0) {
132
+ sections.push('### ⚠️ BREAKING CHANGES');
133
+ sections.push(...breakingChanges.map((part) => {
134
+ const scopePart = part.scope ? ` (${part.scope})` : '';
135
+ const linkPart = repoUrl ? ` ([${part.sha}](${repoUrl}/commit/${part.sha}))` : ` (${part.sha})`;
136
+ return `- ${part.description}${scopePart}${linkPart}`;
137
+ }));
138
+ sections.push('');
139
+ }
140
+ for (const sectionTitle of sectionOrder) {
141
+ if (groupedParts[sectionTitle] && groupedParts[sectionTitle].length > 0) {
142
+ sections.push(sectionTitle);
143
+ sections.push(...groupedParts[sectionTitle].map((part) => {
144
+ const scopePart = part.scope ? ` (${part.scope})` : '';
145
+ const breakingIndicator = part.breaking ? ' ⚠️ BREAKING' : '';
146
+ const linkPart = repoUrl ? ` ([${part.sha}](${repoUrl}/commit/${part.sha}))` : ` (${part.sha})`;
147
+ return `- ${part.description}${scopePart}${breakingIndicator}${linkPart}`;
148
+ }));
149
+ sections.push('');
150
+ }
151
+ }
152
+ return sections.length > 0 ? sections.join('\n').trim() : 'No changes yet.';
153
+ }
154
+ /**
155
+ * Main function to populate changelog with dependency injection
156
+ */
157
+ export function populateChangelog(deps) {
158
+ const changelogPath = deps.getEnv('CHANGELOG_FILE') || 'CHANGELOG.md';
159
+ deps.log('📝 Populating [Unreleased] section...');
160
+ let latestTag;
161
+ try {
162
+ latestTag = deps.execSync('git describe --tags --abbrev=0 2>/dev/null', { encoding: 'utf8' }).trim();
163
+ deps.log(`ℹ️ Latest tag: ${latestTag}`);
164
+ }
165
+ catch {
166
+ deps.log('ℹ️ No tags found, using all commits');
167
+ latestTag = '';
168
+ }
169
+ const gitLogCommand = latestTag
170
+ ? `git log --pretty=format:"%H|%B|||END|||" ${latestTag}..HEAD`
171
+ : `git log --pretty=format:"%H|%B|||END|||"`;
172
+ let gitOutput;
173
+ try {
174
+ gitOutput = deps.execSync(gitLogCommand, { encoding: 'utf8' }).trim();
175
+ }
176
+ catch {
177
+ deps.log('ℹ️ No new commits found');
178
+ gitOutput = '';
179
+ }
180
+ const repoUrl = getGitHubRepoUrl({
181
+ execSync: deps.execSync,
182
+ getEnv: deps.getEnv,
183
+ warn: deps.warn,
184
+ });
185
+ const commits = parseCommitsWithMultiplePrefixes(gitOutput, repoUrl);
186
+ const changelog = deps.readFileSync(changelogPath, 'utf8');
187
+ const unreleasedContent = commits && commits.trim() ? commits : 'No changes yet.';
188
+ const unreleasedRegex = /## \[Unreleased\][\s\S]*?(?=## \[|$)/;
189
+ const newUnreleasedSection = `## [Unreleased]\n\n${unreleasedContent}\n\n`;
190
+ let updatedChangelog;
191
+ if (changelog.match(unreleasedRegex)) {
192
+ updatedChangelog = changelog.replace(unreleasedRegex, newUnreleasedSection);
193
+ }
194
+ else {
195
+ const doubleNewlineIndex = changelog.indexOf('\n\n');
196
+ if (doubleNewlineIndex === -1) {
197
+ const trimmed = changelog.trimEnd();
198
+ const separator = trimmed.length === 0 ? '' : '\n\n';
199
+ updatedChangelog = `${trimmed}${separator}${newUnreleasedSection}`;
200
+ }
201
+ else {
202
+ const insertionPoint = doubleNewlineIndex + 2;
203
+ updatedChangelog = changelog.slice(0, insertionPoint) + newUnreleasedSection + changelog.slice(insertionPoint);
204
+ }
205
+ }
206
+ deps.writeFileSync(changelogPath, updatedChangelog);
207
+ const commitCount = unreleasedContent === 'No changes yet.'
208
+ ? 0
209
+ : unreleasedContent
210
+ .split('\n')
211
+ .filter((line) => line.trim().startsWith('- '))
212
+ .length;
213
+ deps.log(`✅ Updated [Unreleased] section with ${commitCount} commit(s)`);
214
+ }
215
+ /**
216
+ * CLI entry point - only runs when script is executed directly
217
+ */
218
+ /* c8 ignore start */
219
+ if (import.meta.url === `file://${process.argv[1]}`) {
220
+ try {
221
+ populateChangelog({
222
+ execSync,
223
+ readFileSync,
224
+ writeFileSync,
225
+ getEnv: (key) => process.env[key],
226
+ log: console.log,
227
+ warn: console.warn,
228
+ error: console.error,
229
+ });
230
+ }
231
+ catch (error) {
232
+ console.error('❌ Failed to populate [Unreleased] section:', error);
233
+ process.exit(1);
234
+ }
235
+ }
236
+ /* c8 ignore end */