@versatiles/release-tool 2.6.0 → 2.7.1

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.
@@ -1,10 +1,16 @@
1
1
  #!/usr/bin/env npx tsx
2
2
  import { readFileSync, writeFileSync } from 'fs';
3
+ import { resolve } from 'path';
3
4
  import select from '@inquirer/select';
5
+ import { updateChangelog } from '../lib/changelog.js';
6
+ import { releaseError, validationError } from '../lib/errors.js';
7
+ import { COMMIT_TYPES, getGit, getSuggestedBump, groupCommitsByType, parseConventionalCommit, } from '../lib/git.js';
4
8
  import { check, info, panic, warn } from '../lib/log.js';
9
+ import { withRetry } from '../lib/retry.js';
5
10
  import { Shell } from '../lib/shell.js';
6
- import { getGit } from '../lib/git.js';
7
- import { resolve } from 'path';
11
+ /**
12
+ * Type guard to validate package.json structure.
13
+ */
8
14
  function isValidPackageJson(pkg) {
9
15
  if (typeof pkg !== 'object' || pkg === null)
10
16
  return false;
@@ -14,6 +20,38 @@ function isValidPackageJson(pkg) {
14
20
  return false;
15
21
  return true;
16
22
  }
23
+ /**
24
+ * Executes the npm release process.
25
+ *
26
+ * This function performs a complete release workflow:
27
+ * 1. Validates git state (correct branch, no uncommitted changes)
28
+ * 2. Pulls latest changes from remote
29
+ * 3. Verifies npm authentication
30
+ * 4. Prompts for new version (with suggestion based on conventional commits)
31
+ * 5. Runs project checks
32
+ * 6. Updates package.json version
33
+ * 7. Updates CHANGELOG.md
34
+ * 8. Publishes to npm (if not private)
35
+ * 9. Creates git commit and tag
36
+ * 10. Pushes to remote and creates GitHub release
37
+ *
38
+ * @param directory - The project directory containing package.json
39
+ * @param branch - The git branch to release from (default: 'main')
40
+ * @param dryRun - If true, simulate the release without making changes
41
+ * @throws {VrtError} If any step in the release process fails
42
+ *
43
+ * @example
44
+ * ```ts
45
+ * // Standard release from main branch
46
+ * await release('/path/to/project');
47
+ *
48
+ * // Dry run to preview release
49
+ * await release('/path/to/project', 'main', true);
50
+ *
51
+ * // Release from a different branch
52
+ * await release('/path/to/project', 'release');
53
+ * ```
54
+ */
17
55
  export async function release(directory, branch = 'main', dryRun = false) {
18
56
  const shell = new Shell(directory);
19
57
  const { getCommitsBetween, getCurrentGitHubCommit, getLastGitHubTag } = getGit(directory);
@@ -24,8 +62,10 @@ export async function release(directory, branch = 'main', dryRun = false) {
24
62
  panic(`current branch is "${currentBranch}" but should be "${branch}"`);
25
63
  // git: check if no changes
26
64
  await check('are all changes committed?', checkThatNoUncommittedChanges());
27
- // git: pull
28
- await check('git pull', shell.run('git pull -t'));
65
+ // git: pull (with retry for transient network failures)
66
+ await check('git pull', withRetry(() => shell.run('git pull -t'), {
67
+ onRetry: (attempt, error) => warn(`git pull failed (attempt ${attempt}): ${error.message}, retrying...`),
68
+ }));
29
69
  // check package.json
30
70
  const pkgRaw = JSON.parse(readFileSync(resolve(directory, 'package.json'), 'utf8'));
31
71
  if (!isValidPackageJson(pkgRaw))
@@ -35,6 +75,11 @@ export async function release(directory, branch = 'main', dryRun = false) {
35
75
  if (!('prepack' in pkgRaw.scripts))
36
76
  panic('missing npm script "prepack" in package.json');
37
77
  const pkg = pkgRaw;
78
+ const isPrivatePackage = pkg.private === true;
79
+ // npm: verify authentication (skip for private packages)
80
+ if (!isPrivatePackage) {
81
+ await check('verify npm authentication', verifyNpmAuth());
82
+ }
38
83
  // get last version
39
84
  const tag = await check('get last github tag', getLastGitHubTag());
40
85
  const shaLast = tag?.sha;
@@ -44,10 +89,14 @@ export async function release(directory, branch = 'main', dryRun = false) {
44
89
  warn(`versions differ in package.json (${versionLastPackage}) and last GitHub tag (${versionLastGithub})`);
45
90
  // get current sha
46
91
  const { sha: shaCurrent } = await check('get current github commit', getCurrentGitHubCommit());
47
- // handle version
48
- const nextVersion = await getNewVersion(versionLastPackage);
49
- // prepare release notes
50
- const releaseNotes = await check('prepare release notes', getReleaseNotes(nextVersion, shaLast, shaCurrent));
92
+ // get and parse commits for conventional commit support
93
+ const commits = await check('get commits since last release', getCommitsBetween(shaLast, shaCurrent));
94
+ const parsedCommits = commits.map(parseConventionalCommit);
95
+ // handle version (with suggested bump based on conventional commits)
96
+ const nextVersion = await getNewVersion(versionLastPackage, parsedCommits);
97
+ // prepare release notes (grouped by conventional commit type)
98
+ const releaseNotes = getReleaseNotes(nextVersion, parsedCommits);
99
+ info('prepared release notes');
51
100
  if (dryRun) {
52
101
  info('Dry-run mode - the following actions would be performed:');
53
102
  info(` Version: ${versionLastPackage} -> ${nextVersion}`);
@@ -56,13 +105,14 @@ export async function release(directory, branch = 'main', dryRun = false) {
56
105
  info(' Commands that would be executed:');
57
106
  info(' npm run check');
58
107
  info(' npm i --package-lock-only');
59
- if (!('private' in pkg) || !pkg.private) {
108
+ info(' Update CHANGELOG.md');
109
+ if (!isPrivatePackage) {
60
110
  info(' npm publish --access public');
61
111
  }
62
112
  info(' git add .');
63
113
  info(` git commit -m "v${nextVersion}"`);
64
114
  info(` git tag -f -a "v${nextVersion}" -m "new release: v${nextVersion}"`);
65
- info(' git push --no-verify --follow-tags');
115
+ info(' git push --atomic --no-verify --follow-tags');
66
116
  info(` gh release create/edit "v${nextVersion}"`);
67
117
  info('Dry-run complete - no changes were made');
68
118
  return;
@@ -71,29 +121,58 @@ export async function release(directory, branch = 'main', dryRun = false) {
71
121
  await check('run checks', shell.run('npm run check'));
72
122
  // update version
73
123
  await check('update version', setNextVersion(nextVersion));
74
- if (!('private' in pkg) || !pkg.private) {
75
- // npm publish
76
- await check('npm publish', shell.runInteractive('npm publish --access public'));
124
+ // update changelog
125
+ const changelogResult = updateChangelog(directory, nextVersion, parsedCommits);
126
+ if (changelogResult.created) {
127
+ info('created CHANGELOG.md');
128
+ }
129
+ else {
130
+ info('updated CHANGELOG.md');
131
+ }
132
+ if (!isPrivatePackage) {
133
+ // npm publish (with retry for transient network failures)
134
+ await check('npm publish', withRetry(() => shell.runInteractive('npm publish --access public'), {
135
+ onRetry: (attempt, error) => warn(`npm publish failed (attempt ${attempt}): ${error.message}, retrying...`),
136
+ }));
77
137
  }
78
138
  // git push
79
139
  await check('git add', shell.run('git add .'));
80
- await check('git commit', shell.run(`git commit -m "v${nextVersion}"`, false));
140
+ await check('git commit', shell.run(`git commit -m "v${nextVersion}"`));
81
141
  await check('git tag', shell.run(`git tag -f -a "v${nextVersion}" -m "new release: v${nextVersion}"`));
82
- await check('git push', shell.run('git push --no-verify --follow-tags'));
83
- // github release
142
+ await check('git push', withRetry(() => shell.run('git push --atomic --no-verify --follow-tags'), {
143
+ onRetry: (attempt, error) => warn(`git push failed (attempt ${attempt}): ${error.message}, retrying...`),
144
+ }));
145
+ // github release (with retry for transient network failures)
84
146
  const releaseTag = `v${nextVersion}`;
85
147
  if (await check('check github release', shell.ok(`gh release view ${releaseTag}`))) {
86
- await check('edit release', shell.exec('gh', ['release', 'edit', releaseTag, '--notes', releaseNotes]));
148
+ await check('edit release', withRetry(() => shell.exec('gh', ['release', 'edit', releaseTag, '--notes', releaseNotes]), {
149
+ onRetry: (attempt, error) => warn(`gh release edit failed (attempt ${attempt}): ${error.message}, retrying...`),
150
+ }));
87
151
  }
88
152
  else {
89
- await check('create release', shell.exec('gh', ['release', 'create', releaseTag, '--notes', releaseNotes]));
153
+ await check('create release', withRetry(() => shell.exec('gh', ['release', 'create', releaseTag, '--notes', releaseNotes]), {
154
+ onRetry: (attempt, error) => warn(`gh release create failed (attempt ${attempt}): ${error.message}, retrying...`),
155
+ }));
90
156
  }
91
157
  info('Finished');
92
158
  return;
159
+ async function verifyNpmAuth() {
160
+ try {
161
+ const username = await shell.stdout('npm whoami');
162
+ if (!username || username.trim().length === 0) {
163
+ throw releaseError('npm authentication failed: no username returned');
164
+ }
165
+ info(`authenticated as npm user: ${username.trim()}`);
166
+ }
167
+ catch {
168
+ throw releaseError('npm authentication required. Please run "npm login" first.\n' +
169
+ 'If you are using a CI environment, ensure NPM_TOKEN is set.');
170
+ }
171
+ }
93
172
  async function checkThatNoUncommittedChanges() {
94
173
  if ((await shell.stdout('git status --porcelain')).length < 3)
95
174
  return;
96
- throw Error('please commit all changes before releasing');
175
+ throw releaseError('please commit all changes before releasing');
97
176
  }
98
177
  async function setNextVersion(version) {
99
178
  // set new version in package.json
@@ -103,30 +182,76 @@ export async function release(directory, branch = 'main', dryRun = false) {
103
182
  // rebuild package.json
104
183
  await shell.run('npm i --package-lock-only');
105
184
  }
106
- async function getReleaseNotes(version, hashLast, hashCurrent) {
107
- const commits = await getCommitsBetween(hashLast, hashCurrent);
108
- let notes = commits
109
- .reverse()
110
- .map((commit) => '- ' + commit.message.replace(/\s+/g, ' '))
111
- .join('\n');
112
- notes = `# Release v${version}\n\nchanges:\n${notes}\n\n`;
185
+ function getReleaseNotes(version, commits) {
186
+ const reversed = [...commits].reverse();
187
+ const grouped = groupCommitsByType(reversed);
188
+ let notes = `# Release v${version}\n\n`;
189
+ // Order: breaking changes first, then features, fixes, and others
190
+ const typeOrder = [
191
+ 'feat',
192
+ 'fix',
193
+ 'perf',
194
+ 'refactor',
195
+ 'docs',
196
+ 'test',
197
+ 'build',
198
+ 'ci',
199
+ 'chore',
200
+ 'style',
201
+ 'revert',
202
+ 'other',
203
+ ];
204
+ // Add breaking changes section if any
205
+ const breakingCommits = reversed.filter((c) => c.breaking);
206
+ if (breakingCommits.length > 0) {
207
+ notes += '## Breaking Changes\n\n';
208
+ for (const commit of breakingCommits) {
209
+ notes += `- ${commit.description.replace(/\s+/g, ' ')}\n`;
210
+ }
211
+ notes += '\n';
212
+ }
213
+ // Add grouped commits
214
+ for (const type of typeOrder) {
215
+ const typeCommits = grouped.get(type);
216
+ if (!typeCommits || typeCommits.length === 0)
217
+ continue;
218
+ // Skip commits already shown in breaking changes
219
+ const nonBreaking = typeCommits.filter((c) => !c.breaking);
220
+ if (nonBreaking.length === 0)
221
+ continue;
222
+ const label = type === 'other' ? 'Other Changes' : COMMIT_TYPES[type];
223
+ notes += `## ${label}\n\n`;
224
+ for (const commit of nonBreaking) {
225
+ const scope = commit.scope ? `**${commit.scope}:** ` : '';
226
+ notes += `- ${scope}${commit.description.replace(/\s+/g, ' ')}\n`;
227
+ }
228
+ notes += '\n';
229
+ }
113
230
  return notes;
114
231
  }
115
- async function getNewVersion(versionPackage) {
116
- // ask for new version
232
+ async function getNewVersion(versionPackage, commits) {
233
+ // Determine suggested bump based on conventional commits
234
+ const suggestedBump = getSuggestedBump(commits);
235
+ // choices: [current, patch, minor, major] -> indices [0, 1, 2, 3]
236
+ const suggestedIndex = suggestedBump === 'major' ? 3 : suggestedBump === 'minor' ? 2 : 1;
117
237
  const choices = [{ value: versionPackage }, { ...bump(2) }, { ...bump(1) }, { ...bump(0) }];
238
+ // Add recommendation label to suggested version
239
+ const suggestedChoice = choices[suggestedIndex];
240
+ if (suggestedChoice && 'name' in suggestedChoice) {
241
+ suggestedChoice.name += ' (recommended)';
242
+ }
118
243
  const versionNew = await select({
119
244
  message: 'What should be the new version?',
120
245
  choices,
121
- default: choices[1].value,
246
+ default: suggestedChoice?.value ?? choices[1].value,
122
247
  });
123
248
  if (!versionNew)
124
- throw Error();
249
+ throw releaseError('no version selected');
125
250
  return versionNew;
126
251
  function bump(index) {
127
252
  const p = versionPackage.split('.').map((v) => parseInt(v, 10));
128
253
  if (p.length !== 3)
129
- throw Error();
254
+ throw validationError('invalid version format, expected x.y.z');
130
255
  switch (index) {
131
256
  case 0:
132
257
  p[0]++;
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Performance benchmarking utilities for CLI operations.
3
+ *
4
+ * Provides timing measurement and optional logging for tracking
5
+ * execution duration of key operations.
6
+ */
7
+ /**
8
+ * Result of a benchmarked operation
9
+ */
10
+ export interface BenchmarkResult<T> {
11
+ /** The result returned by the benchmarked function */
12
+ result: T;
13
+ /** Execution duration in milliseconds */
14
+ durationMs: number;
15
+ /** Human-readable duration string */
16
+ durationFormatted: string;
17
+ }
18
+ /**
19
+ * Options for benchmark execution
20
+ */
21
+ export interface BenchmarkOptions {
22
+ /** Label for the operation being benchmarked */
23
+ label?: string;
24
+ /** Whether to log timing to console */
25
+ log?: boolean;
26
+ /** Custom logger function (defaults to console.log) */
27
+ logger?: (message: string) => void;
28
+ }
29
+ /**
30
+ * Formats a duration in milliseconds to a human-readable string.
31
+ *
32
+ * @param ms - Duration in milliseconds
33
+ * @returns Formatted duration string (e.g., "1.23s", "456ms")
34
+ */
35
+ export declare function formatDuration(ms: number): string;
36
+ /**
37
+ * Measures the execution time of a synchronous function.
38
+ *
39
+ * @param fn - The function to benchmark
40
+ * @param options - Optional benchmark configuration
41
+ * @returns The function result along with timing information
42
+ *
43
+ * @example
44
+ * ```ts
45
+ * const { result, durationMs } = benchmarkSync(() => {
46
+ * return heavyComputation();
47
+ * }, { label: 'Heavy computation', log: true });
48
+ * ```
49
+ */
50
+ export declare function benchmarkSync<T>(fn: () => T, options?: BenchmarkOptions): BenchmarkResult<T>;
51
+ /**
52
+ * Measures the execution time of an asynchronous function.
53
+ *
54
+ * @param fn - The async function to benchmark
55
+ * @param options - Optional benchmark configuration
56
+ * @returns Promise resolving to the function result along with timing information
57
+ *
58
+ * @example
59
+ * ```ts
60
+ * const { result, durationMs } = await benchmark(async () => {
61
+ * return await fetchData();
62
+ * }, { label: 'API fetch', log: true });
63
+ * ```
64
+ */
65
+ export declare function benchmark<T>(fn: () => Promise<T>, options?: BenchmarkOptions): Promise<BenchmarkResult<T>>;
66
+ /**
67
+ * Creates a timer for manual timing control.
68
+ *
69
+ * @returns Timer object with start, stop, and elapsed methods
70
+ *
71
+ * @example
72
+ * ```ts
73
+ * const timer = createTimer();
74
+ * timer.start();
75
+ * // ... do work ...
76
+ * const elapsed = timer.stop();
77
+ * console.log(`Operation took ${elapsed.durationFormatted}`);
78
+ * ```
79
+ */
80
+ export declare function createTimer(): {
81
+ start: () => void;
82
+ stop: () => {
83
+ durationMs: number;
84
+ durationFormatted: string;
85
+ };
86
+ elapsed: () => number;
87
+ isRunning: () => boolean;
88
+ };
89
+ /**
90
+ * Aggregates multiple benchmark results for statistical analysis.
91
+ */
92
+ export interface BenchmarkStats {
93
+ /** Number of samples */
94
+ count: number;
95
+ /** Total duration in milliseconds */
96
+ totalMs: number;
97
+ /** Average duration in milliseconds */
98
+ avgMs: number;
99
+ /** Minimum duration in milliseconds */
100
+ minMs: number;
101
+ /** Maximum duration in milliseconds */
102
+ maxMs: number;
103
+ /** Formatted average duration */
104
+ avgFormatted: string;
105
+ }
106
+ /**
107
+ * Calculates statistics from an array of benchmark durations.
108
+ *
109
+ * @param durations - Array of duration values in milliseconds
110
+ * @returns Statistical summary of the benchmarks
111
+ *
112
+ * @example
113
+ * ```ts
114
+ * const durations = [100, 150, 120, 130, 110];
115
+ * const stats = calculateStats(durations);
116
+ * console.log(`Average: ${stats.avgFormatted}`);
117
+ * ```
118
+ */
119
+ export declare function calculateStats(durations: number[]): BenchmarkStats;
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Performance benchmarking utilities for CLI operations.
3
+ *
4
+ * Provides timing measurement and optional logging for tracking
5
+ * execution duration of key operations.
6
+ */
7
+ /**
8
+ * Formats a duration in milliseconds to a human-readable string.
9
+ *
10
+ * @param ms - Duration in milliseconds
11
+ * @returns Formatted duration string (e.g., "1.23s", "456ms")
12
+ */
13
+ export function formatDuration(ms) {
14
+ if (ms >= 1000) {
15
+ return `${(ms / 1000).toFixed(2)}s`;
16
+ }
17
+ return `${Math.round(ms)}ms`;
18
+ }
19
+ /**
20
+ * Measures the execution time of a synchronous function.
21
+ *
22
+ * @param fn - The function to benchmark
23
+ * @param options - Optional benchmark configuration
24
+ * @returns The function result along with timing information
25
+ *
26
+ * @example
27
+ * ```ts
28
+ * const { result, durationMs } = benchmarkSync(() => {
29
+ * return heavyComputation();
30
+ * }, { label: 'Heavy computation', log: true });
31
+ * ```
32
+ */
33
+ export function benchmarkSync(fn, options = {}) {
34
+ const { label, log = false, logger = console.log } = options;
35
+ const start = performance.now();
36
+ const result = fn();
37
+ const end = performance.now();
38
+ const durationMs = end - start;
39
+ const durationFormatted = formatDuration(durationMs);
40
+ if (log && label) {
41
+ logger(`[benchmark] ${label}: ${durationFormatted}`);
42
+ }
43
+ return { result, durationMs, durationFormatted };
44
+ }
45
+ /**
46
+ * Measures the execution time of an asynchronous function.
47
+ *
48
+ * @param fn - The async function to benchmark
49
+ * @param options - Optional benchmark configuration
50
+ * @returns Promise resolving to the function result along with timing information
51
+ *
52
+ * @example
53
+ * ```ts
54
+ * const { result, durationMs } = await benchmark(async () => {
55
+ * return await fetchData();
56
+ * }, { label: 'API fetch', log: true });
57
+ * ```
58
+ */
59
+ export async function benchmark(fn, options = {}) {
60
+ const { label, log = false, logger = console.log } = options;
61
+ const start = performance.now();
62
+ const result = await fn();
63
+ const end = performance.now();
64
+ const durationMs = end - start;
65
+ const durationFormatted = formatDuration(durationMs);
66
+ if (log && label) {
67
+ logger(`[benchmark] ${label}: ${durationFormatted}`);
68
+ }
69
+ return { result, durationMs, durationFormatted };
70
+ }
71
+ /**
72
+ * Creates a timer for manual timing control.
73
+ *
74
+ * @returns Timer object with start, stop, and elapsed methods
75
+ *
76
+ * @example
77
+ * ```ts
78
+ * const timer = createTimer();
79
+ * timer.start();
80
+ * // ... do work ...
81
+ * const elapsed = timer.stop();
82
+ * console.log(`Operation took ${elapsed.durationFormatted}`);
83
+ * ```
84
+ */
85
+ export function createTimer() {
86
+ let startTime = null;
87
+ let endTime = null;
88
+ return {
89
+ start() {
90
+ startTime = performance.now();
91
+ endTime = null;
92
+ },
93
+ stop() {
94
+ if (startTime === null) {
95
+ throw new Error('Timer was not started');
96
+ }
97
+ endTime = performance.now();
98
+ const durationMs = endTime - startTime;
99
+ return { durationMs, durationFormatted: formatDuration(durationMs) };
100
+ },
101
+ elapsed() {
102
+ if (startTime === null) {
103
+ return 0;
104
+ }
105
+ const end = endTime ?? performance.now();
106
+ return end - startTime;
107
+ },
108
+ isRunning() {
109
+ return startTime !== null && endTime === null;
110
+ },
111
+ };
112
+ }
113
+ /**
114
+ * Calculates statistics from an array of benchmark durations.
115
+ *
116
+ * @param durations - Array of duration values in milliseconds
117
+ * @returns Statistical summary of the benchmarks
118
+ *
119
+ * @example
120
+ * ```ts
121
+ * const durations = [100, 150, 120, 130, 110];
122
+ * const stats = calculateStats(durations);
123
+ * console.log(`Average: ${stats.avgFormatted}`);
124
+ * ```
125
+ */
126
+ export function calculateStats(durations) {
127
+ if (durations.length === 0) {
128
+ return {
129
+ count: 0,
130
+ totalMs: 0,
131
+ avgMs: 0,
132
+ minMs: 0,
133
+ maxMs: 0,
134
+ avgFormatted: '0ms',
135
+ };
136
+ }
137
+ const totalMs = durations.reduce((sum, d) => sum + d, 0);
138
+ const avgMs = totalMs / durations.length;
139
+ return {
140
+ count: durations.length,
141
+ totalMs,
142
+ avgMs,
143
+ minMs: Math.min(...durations),
144
+ maxMs: Math.max(...durations),
145
+ avgFormatted: formatDuration(avgMs),
146
+ };
147
+ }
148
+ //# sourceMappingURL=benchmark.js.map
@@ -0,0 +1,23 @@
1
+ import { type ParsedCommit } from './git.js';
2
+ /**
3
+ * Generates a changelog entry for a release from parsed commits.
4
+ *
5
+ * @param version - The version being released
6
+ * @param commits - Array of parsed conventional commits
7
+ * @param date - The release date (defaults to today)
8
+ * @returns Formatted changelog entry string
9
+ */
10
+ export declare function generateChangelogEntry(version: string, commits: ParsedCommit[], date?: Date): string;
11
+ /**
12
+ * Updates or creates a CHANGELOG.md file with a new release entry.
13
+ *
14
+ * @param directory - The project directory containing CHANGELOG.md
15
+ * @param version - The version being released
16
+ * @param commits - Array of parsed conventional commits
17
+ * @param date - The release date (defaults to today)
18
+ * @returns Object indicating whether the file was created or updated
19
+ */
20
+ export declare function updateChangelog(directory: string, version: string, commits: ParsedCommit[], date?: Date): {
21
+ created: boolean;
22
+ path: string;
23
+ };
@@ -0,0 +1,117 @@
1
+ import { existsSync, readFileSync, writeFileSync } from 'fs';
2
+ import { resolve } from 'path';
3
+ import { COMMIT_TYPES, groupCommitsByType } from './git.js';
4
+ /**
5
+ * Generates a changelog entry for a release from parsed commits.
6
+ *
7
+ * @param version - The version being released
8
+ * @param commits - Array of parsed conventional commits
9
+ * @param date - The release date (defaults to today)
10
+ * @returns Formatted changelog entry string
11
+ */
12
+ export function generateChangelogEntry(version, commits, date = new Date()) {
13
+ const dateStr = date.toISOString().split('T')[0];
14
+ const reversed = [...commits].reverse();
15
+ const grouped = groupCommitsByType(reversed);
16
+ let entry = `## [${version}] - ${dateStr}\n\n`;
17
+ // Order: breaking changes first, then features, fixes, and others
18
+ const typeOrder = [
19
+ 'feat',
20
+ 'fix',
21
+ 'perf',
22
+ 'refactor',
23
+ 'docs',
24
+ 'test',
25
+ 'build',
26
+ 'ci',
27
+ 'chore',
28
+ 'style',
29
+ 'revert',
30
+ 'other',
31
+ ];
32
+ // Add breaking changes section if any
33
+ const breakingCommits = reversed.filter((c) => c.breaking);
34
+ if (breakingCommits.length > 0) {
35
+ entry += '### Breaking Changes\n\n';
36
+ for (const commit of breakingCommits) {
37
+ entry += `- ${commit.description.replace(/\s+/g, ' ')}\n`;
38
+ }
39
+ entry += '\n';
40
+ }
41
+ // Add grouped commits
42
+ for (const type of typeOrder) {
43
+ const typeCommits = grouped.get(type);
44
+ if (!typeCommits || typeCommits.length === 0)
45
+ continue;
46
+ // Skip commits already shown in breaking changes
47
+ const nonBreaking = typeCommits.filter((c) => !c.breaking);
48
+ if (nonBreaking.length === 0)
49
+ continue;
50
+ const label = type === 'other' ? 'Other Changes' : COMMIT_TYPES[type];
51
+ entry += `### ${label}\n\n`;
52
+ for (const commit of nonBreaking) {
53
+ const scope = commit.scope ? `**${commit.scope}:** ` : '';
54
+ entry += `- ${scope}${commit.description.replace(/\s+/g, ' ')}\n`;
55
+ }
56
+ entry += '\n';
57
+ }
58
+ return entry;
59
+ }
60
+ /**
61
+ * Default changelog header for new CHANGELOG.md files
62
+ */
63
+ const DEFAULT_CHANGELOG_HEADER = `# Changelog
64
+
65
+ All notable changes to this project will be documented in this file.
66
+
67
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
68
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
69
+
70
+ `;
71
+ /**
72
+ * Updates or creates a CHANGELOG.md file with a new release entry.
73
+ *
74
+ * @param directory - The project directory containing CHANGELOG.md
75
+ * @param version - The version being released
76
+ * @param commits - Array of parsed conventional commits
77
+ * @param date - The release date (defaults to today)
78
+ * @returns Object indicating whether the file was created or updated
79
+ */
80
+ export function updateChangelog(directory, version, commits, date = new Date()) {
81
+ const changelogPath = resolve(directory, 'CHANGELOG.md');
82
+ const entry = generateChangelogEntry(version, commits, date);
83
+ let content;
84
+ let created = false;
85
+ if (existsSync(changelogPath)) {
86
+ const existing = readFileSync(changelogPath, 'utf8');
87
+ // Insert new entry after the header (after the first double newline or at the start if no header)
88
+ const headerEndIndex = findHeaderEnd(existing);
89
+ content = existing.slice(0, headerEndIndex) + entry + existing.slice(headerEndIndex);
90
+ }
91
+ else {
92
+ content = DEFAULT_CHANGELOG_HEADER + entry;
93
+ created = true;
94
+ }
95
+ writeFileSync(changelogPath, content);
96
+ return { created, path: changelogPath };
97
+ }
98
+ /**
99
+ * Finds the end of the changelog header section.
100
+ * Looks for patterns like "# Changelog" followed by description text,
101
+ * ending before the first "## " section header.
102
+ */
103
+ function findHeaderEnd(content) {
104
+ // Look for the first version section (## [x.y.z] or ## x.y.z)
105
+ const versionSectionMatch = content.match(/^## \[?\d+\.\d+\.\d+\]?/m);
106
+ if (versionSectionMatch?.index !== undefined) {
107
+ return versionSectionMatch.index;
108
+ }
109
+ // If no version section found, look for any ## heading
110
+ const anySectionMatch = content.match(/^## /m);
111
+ if (anySectionMatch?.index !== undefined) {
112
+ return anySectionMatch.index;
113
+ }
114
+ // No sections found, append at end (after ensuring trailing newline)
115
+ return content.endsWith('\n') ? content.length : content.length;
116
+ }
117
+ //# sourceMappingURL=changelog.js.map