calver-bump 0.1.3 → 0.1.4

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "calver-bump",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "Release CLI for internal applications using readable CalVer versions.",
5
5
  "main": "src/index.js",
6
6
  "bin": {
package/src/index.js CHANGED
@@ -118,8 +118,6 @@ async function fileExists(filePath) {
118
118
 
119
119
  async function prependChangelog(cwd, version, date, options = {}) {
120
120
  const changelogPath = path.join(cwd, 'CHANGELOG.md');
121
- const changes = await releaseNotes(cwd, options);
122
- const entry = `## ${version} - ${isoDate(date)}\n\n${formatReleaseNotes(changes)}\n`;
123
121
  let existing = '';
124
122
 
125
123
  try {
@@ -128,6 +126,16 @@ async function prependChangelog(cwd, version, date, options = {}) {
128
126
  if (error.code !== 'ENOENT') throw error;
129
127
  }
130
128
 
129
+ const notes = await releaseNotes(cwd, { ...options, existingChangelog: existing });
130
+ const heading = formatReleaseHeading({
131
+ version,
132
+ date,
133
+ previousTag: notes.previousTag,
134
+ tag: `${options.tagPrefix ?? ''}${version}`,
135
+ compareUrlBuilder: notes.compareUrlBuilder,
136
+ });
137
+ const entry = `${heading}\n\n${formatReleaseNotes(notes.changes)}\n`;
138
+
131
139
  const body = existing.trim().startsWith('# Changelog')
132
140
  ? existing.replace(/^# Changelog\s*/, `# Changelog\n\n${entry}\n`)
133
141
  : `# Changelog\n\n${entry}\n${existing}`;
@@ -136,19 +144,39 @@ async function prependChangelog(cwd, version, date, options = {}) {
136
144
  }
137
145
 
138
146
  async function releaseNotes(cwd, options = {}) {
147
+ await fetchTags(cwd, options.remote ?? 'origin');
139
148
  const latestTag = await latestReachableTag(cwd);
140
149
  const range = latestTag ? [`${latestTag}..HEAD`] : [];
141
150
  const commits = await gitCommits(cwd, range);
142
- const commitUrlBuilder = await commitUrlBuilderForOrigin(cwd);
151
+ const remoteUrl = await getRemoteUrl(cwd, options.remote ?? 'origin');
152
+ const commitUrlBuilder = remoteUrl ? buildCommitUrlBuilder(remoteUrl) : null;
153
+ const compareUrlBuilder = remoteUrl ? buildCompareUrlBuilder(remoteUrl) : null;
143
154
  const allowedTypes = options.types ?? ['feat', 'fix', 'docs', 'style', 'refactor', 'perf', 'test', 'build', 'ci', 'chore', 'revert'];
144
155
  const conventionalCommits = commits
145
156
  .filter((commit) => isConventionalCommit(commit.subject))
146
157
  .filter((commit) => allowedTypes.includes(conventionalType(commit.subject)))
158
+ .filter((commit) => !isCommitInChangelog(commit, options.existingChangelog ?? ''))
147
159
  .map((commit) => ({
148
160
  ...commit,
149
161
  url: commitUrlBuilder ? commitUrlBuilder(commit.hash) : null,
150
162
  }));
151
- return conventionalCommits.length > 0 ? conventionalCommits : ['No conventional commits in this release.'];
163
+ return {
164
+ previousTag: latestTag,
165
+ compareUrlBuilder,
166
+ changes: conventionalCommits.length > 0 ? conventionalCommits : ['No conventional commits in this release.'],
167
+ };
168
+ }
169
+
170
+ function formatReleaseHeading({ version, date, previousTag, tag, compareUrlBuilder }) {
171
+ const label = previousTag && compareUrlBuilder
172
+ ? `[${version}](${compareUrlBuilder(previousTag, tag)})`
173
+ : version;
174
+ return `## ${label} - ${isoDate(date)}`;
175
+ }
176
+
177
+ function isCommitInChangelog(commit, changelog) {
178
+ if (!changelog) return false;
179
+ return changelog.includes(commit.hash) || changelog.includes(commit.hash.slice(0, 7));
152
180
  }
153
181
 
154
182
  function isConventionalCommit(subject) {
@@ -196,16 +224,16 @@ async function gitCommits(cwd, range) {
196
224
  });
197
225
  }
198
226
 
199
- async function commitUrlBuilderForOrigin(cwd) {
227
+ async function getRemoteUrl(cwd, remote) {
200
228
  try {
201
- const { stdout } = await git(cwd, ['remote', 'get-url', 'origin']);
202
- return commitUrlBuilder(stdout.trim());
229
+ const { stdout } = await git(cwd, ['remote', 'get-url', remote]);
230
+ return stdout.trim();
203
231
  } catch {
204
232
  return null;
205
233
  }
206
234
  }
207
235
 
208
- function commitUrlBuilder(remote) {
236
+ function buildCommitUrlBuilder(remote) {
209
237
  const parsed = parseGitRemote(remote);
210
238
  if (!parsed) {
211
239
  return null;
@@ -222,6 +250,23 @@ function commitUrlBuilder(remote) {
222
250
  return null;
223
251
  }
224
252
 
253
+ function buildCompareUrlBuilder(remote) {
254
+ const parsed = parseGitRemote(remote);
255
+ if (!parsed) {
256
+ return null;
257
+ }
258
+
259
+ const baseUrl = `https://${parsed.host}/${parsed.repo}`;
260
+ if (parsed.host === 'github.com') {
261
+ return (from, to) => `${baseUrl}/compare/${from}...${to}`;
262
+ }
263
+ if (parsed.host.includes('gitlab')) {
264
+ return (from, to) => `${baseUrl}/-/compare/${from}...${to}`;
265
+ }
266
+
267
+ return null;
268
+ }
269
+
225
270
  function parseGitRemote(remote) {
226
271
  const sshMatch = /^git@(?<host>[^:]+):(?<repo>.+?)(?:\.git)?$/.exec(remote);
227
272
  if (sshMatch) {
@@ -245,6 +290,15 @@ async function latestReachableTag(cwd) {
245
290
  }
246
291
  }
247
292
 
293
+ async function fetchTags(cwd, remote) {
294
+ try {
295
+ await git(cwd, ['remote', 'get-url', remote]);
296
+ await git(cwd, ['fetch', '--tags', remote]);
297
+ } catch {
298
+ // Local/offline repos can still release using tags already present.
299
+ }
300
+ }
301
+
248
302
  async function currentBranch(cwd) {
249
303
  try {
250
304
  const { stdout } = await git(cwd, ['branch', '--show-current']);
@@ -187,6 +187,7 @@ test('runRelease groups changelog entries by conventional commit type', async ()
187
187
  test('runRelease links each changelog entry to its commit on GitHub', async () => {
188
188
  const repo = await makeRepo();
189
189
  execFileSync('git', ['remote', 'add', 'origin', 'git@github.com:msako/demo-app.git'], { cwd: repo });
190
+ execFileSync('git', ['tag', 'v1.0.0'], { cwd: repo });
190
191
  await writeFile(path.join(repo, 'feature.txt'), 'feature\n');
191
192
  execFileSync('git', ['add', 'feature.txt'], { cwd: repo });
192
193
  execFileSync('git', ['commit', '-m', 'feat: add linked changelog entry'], { cwd: repo });
@@ -203,11 +204,16 @@ test('runRelease links each changelog entry to its commit on GitHub', async () =
203
204
  changelog,
204
205
  new RegExp(`- feat: add linked changelog entry \\(\\[${shortHash}\\]\\(https://github\\.com/msako/demo-app/commit/${hash}\\)\\)`),
205
206
  );
207
+ assert.match(
208
+ changelog,
209
+ /^# Changelog\n\n## \[26\.0529\]\(https:\/\/github\.com\/msako\/demo-app\/compare\/v1\.0\.0\.\.\.26\.0529\) - 2026-05-29/,
210
+ );
206
211
  });
207
212
 
208
213
  test('runRelease links changelog entries for private GitLab-style remotes', async () => {
209
214
  const repo = await makeRepo();
210
215
  execFileSync('git', ['remote', 'add', 'origin', 'git@gitlab.internal.example.com:platform/demo-app.git'], { cwd: repo });
216
+ execFileSync('git', ['tag', 'v1.0.0'], { cwd: repo });
211
217
  await writeFile(path.join(repo, 'feature.txt'), 'feature\n');
212
218
  execFileSync('git', ['add', 'feature.txt'], { cwd: repo });
213
219
  execFileSync('git', ['commit', '-m', 'fix: link private gitlab commit'], { cwd: repo });
@@ -224,6 +230,10 @@ test('runRelease links changelog entries for private GitLab-style remotes', asyn
224
230
  changelog,
225
231
  new RegExp(`- fix: link private gitlab commit \\(\\[${shortHash}\\]\\(https://gitlab\\.internal\\.example\\.com/platform/demo-app/-/commit/${hash}\\)\\)`),
226
232
  );
233
+ assert.match(
234
+ changelog,
235
+ /^# Changelog\n\n## \[26\.0529\]\(https:\/\/gitlab\.internal\.example\.com\/platform\/demo-app\/-\/compare\/v1\.0\.0\.\.\.26\.0529\) - 2026-05-29/,
236
+ );
227
237
  });
228
238
 
229
239
  test('runRelease prepends only commits since the previous CalVer tag on later releases', async () => {
@@ -288,6 +298,60 @@ test('runRelease uses the nearest history tag, not the newest-created reachable
288
298
  assert.doesNotMatch(changelog, /feat: initial app/);
289
299
  });
290
300
 
301
+ test('runRelease fetches remote tags before choosing the changelog base', async () => {
302
+ const repo = await makeRepo();
303
+ const remote = await makeBareRepo();
304
+ execFileSync('git', ['remote', 'add', 'origin', remote], { cwd: repo });
305
+ await writeFile(path.join(repo, 'released.txt'), 'released\n');
306
+ execFileSync('git', ['add', 'released.txt'], { cwd: repo });
307
+ execFileSync('git', ['commit', '-m', 'feat: already in remote tagged release'], { cwd: repo });
308
+ execFileSync('git', ['tag', 'v1.35.0'], { cwd: repo });
309
+ execFileSync('git', ['push', 'origin', 'main', '--tags'], { cwd: repo });
310
+ execFileSync('git', ['tag', '-d', 'v1.35.0'], { cwd: repo });
311
+ await writeFile(
312
+ path.join(repo, 'CHANGELOG.md'),
313
+ '# Changelog\n\n## [1.35.0](https://gitlab.ops/example/repo/compare/v1.34.0...v1.35.0) (2026-06-01)\n\n### Features\n\n* feat: already in remote tagged release\n',
314
+ );
315
+ await writeFile(path.join(repo, 'unreleased.txt'), 'unreleased\n');
316
+ execFileSync('git', ['add', 'CHANGELOG.md', 'unreleased.txt'], { cwd: repo });
317
+ execFileSync('git', ['commit', '-m', 'fix: unreleased after remote tag'], { cwd: repo });
318
+
319
+ await runRelease({
320
+ cwd: repo,
321
+ date: new Date('2026-06-02T12:00:00-07:00'),
322
+ });
323
+
324
+ const changelog = await readFile(path.join(repo, 'CHANGELOG.md'), 'utf8');
325
+ const latestEntry = changelog.split('## [1.35.0]')[0];
326
+ assert.match(latestEntry, /- fix: unreleased after remote tag/);
327
+ assert.doesNotMatch(latestEntry, /feat: already in remote tagged release/);
328
+ });
329
+
330
+ test('runRelease does not duplicate commits already present in an existing changelog', async () => {
331
+ const repo = await makeRepo();
332
+ await writeFile(path.join(repo, 'released.txt'), 'released\n');
333
+ execFileSync('git', ['add', 'released.txt'], { cwd: repo });
334
+ execFileSync('git', ['commit', '-m', 'feat: already documented'], { cwd: repo });
335
+ const documentedHash = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: repo, encoding: 'utf8' }).trim();
336
+ await writeFile(
337
+ path.join(repo, 'CHANGELOG.md'),
338
+ `# Changelog\n\n## [1.35.0](https://gitlab.ops/example/repo/compare/v1.34.0...v1.35.0) (2026-06-01)\n\n### Features\n\n* **site-map:** already documented ([${documentedHash.slice(0, 7)}](https://gitlab.ops/example/repo/-/commit/${documentedHash}))\n`,
339
+ );
340
+ await writeFile(path.join(repo, 'unreleased.txt'), 'unreleased\n');
341
+ execFileSync('git', ['add', 'CHANGELOG.md', 'unreleased.txt'], { cwd: repo });
342
+ execFileSync('git', ['commit', '-m', 'fix: not yet documented'], { cwd: repo });
343
+
344
+ await runRelease({
345
+ cwd: repo,
346
+ date: new Date('2026-06-02T12:00:00-07:00'),
347
+ });
348
+
349
+ const changelog = await readFile(path.join(repo, 'CHANGELOG.md'), 'utf8');
350
+ const latestEntry = changelog.split('## [1.35.0]')[0];
351
+ assert.match(latestEntry, /- fix: not yet documented/);
352
+ assert.doesNotMatch(latestEntry, /feat: already documented/);
353
+ });
354
+
291
355
  test('runRelease rolls back its release commit when tag creation fails', async () => {
292
356
  const repo = await makeRepo();
293
357
  execFileSync('git', ['tag', '26.0529'], { cwd: repo });
@@ -361,3 +425,9 @@ async function makeRepo({ packageLock = false } = {}) {
361
425
  execFileSync('git', ['commit', '-m', 'feat: initial app'], { cwd: repo });
362
426
  return repo;
363
427
  }
428
+
429
+ async function makeBareRepo() {
430
+ const repo = await mkdtemp(path.join(tmpdir(), 'calver-bump-remote-'));
431
+ execFileSync('git', ['init', '--bare'], { cwd: repo });
432
+ return repo;
433
+ }