calver-bump 0.1.2 → 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/README.md CHANGED
@@ -18,7 +18,7 @@ Example:
18
18
 
19
19
  1. Bumps `package.json` to the next CalVer version.
20
20
  2. Updates `package-lock.json` or `npm-shrinkwrap.json` when present.
21
- 3. Creates or prepends a `CHANGELOG.md` entry from conventional commits since the latest reachable tag.
21
+ 3. Creates or prepends a `CHANGELOG.md` entry from conventional commits since the nearest reachable tag.
22
22
  4. Creates a release commit.
23
23
  5. Creates an annotated git tag.
24
24
 
@@ -95,11 +95,11 @@ Project defaults can be stored in `.calverbumprc.json`:
95
95
  - The optional `compact` format is `YYMMDD` for the first release of the day, then `YYMMDD.1`, `YYMMDD.2`, etc.
96
96
  - The optional `long` format is `YYYY.MM.DD` for the first release of the day, then `YYYY.MM.DD.1`, `YYYY.MM.DD.2`, etc.
97
97
  - Existing `v`-prefixed tags are considered when calculating the next sequence number.
98
- - Changelog ranges start from the latest reachable tag, even when it is not a CalVer tag.
98
+ - Changelog ranges start from the nearest reachable tag, even when it is not a CalVer tag.
99
99
  - Changelog entries include conventional commit subjects only, such as `feat:`, `fix(scope):`, or `chore!:`.
100
100
  - Changelog entries are grouped into `Features`, `Fixes`, and `Other Changes`.
101
101
  - Changelog entries link to their commit hash for GitHub and GitLab-style `origin` remotes.
102
- - Later releases prepend only commits since the previous reachable tag.
102
+ - Later releases prepend only commits since the previous nearest reachable tag.
103
103
  - Release tags are annotated so `git push --follow-tags <remote> <branch>` pushes them.
104
104
  - The working tree must be clean before creating a real release.
105
105
  - If tag creation fails after the release commit, the CLI rolls back its own release commit.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "calver-bump",
3
- "version": "0.1.2",
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) {
@@ -237,15 +282,21 @@ function parseGitRemote(remote) {
237
282
  }
238
283
 
239
284
  async function latestReachableTag(cwd) {
240
- const tags = await gitLines(cwd, [
241
- 'for-each-ref',
242
- '--merged',
243
- 'HEAD',
244
- '--sort=-creatordate',
245
- '--format=%(refname:short)',
246
- 'refs/tags',
247
- ]);
248
- return tags[0] ?? null;
285
+ try {
286
+ const { stdout } = await git(cwd, ['describe', '--tags', '--abbrev=0']);
287
+ return stdout.trim() || null;
288
+ } catch {
289
+ return null;
290
+ }
291
+ }
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
+ }
249
300
  }
250
301
 
251
302
  async function currentBranch(cwd) {
@@ -104,7 +104,7 @@ test('runRelease updates package-lock.json when it exists', async () => {
104
104
  assert.equal(lock.packages[''].version, '26.0529');
105
105
  });
106
106
 
107
- test('runRelease uses the latest reachable tag as the changelog base', async () => {
107
+ test('runRelease uses the nearest reachable tag as the changelog base', async () => {
108
108
  const repo = await makeRepo();
109
109
  execFileSync('git', ['tag', '26.0528.1'], { cwd: repo });
110
110
  await writeFile(path.join(repo, 'feature-a.txt'), 'a\n');
@@ -122,7 +122,7 @@ test('runRelease uses the latest reachable tag as the changelog base', async ()
122
122
 
123
123
  const changelog = await readFile(path.join(repo, 'CHANGELOG.md'), 'utf8');
124
124
  assert.match(changelog, /- fix: after non-release tag/);
125
- assert.match(changelog, /- feat: after calver tag/);
125
+ assert.doesNotMatch(changelog, /- feat: after calver tag/);
126
126
  assert.doesNotMatch(changelog, /- feat: initial app/);
127
127
  });
128
128
 
@@ -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 () => {
@@ -265,6 +275,83 @@ test('runRelease uses the latest reachable tag as the changelog base even when i
265
275
  assert.doesNotMatch(changelog, /- feat: initial app/);
266
276
  });
267
277
 
278
+ test('runRelease uses the nearest history tag, not the newest-created reachable tag, as the changelog base', async () => {
279
+ const repo = await makeRepo();
280
+ execFileSync('git', ['tag', 'old-tag'], { cwd: repo });
281
+ await writeFile(path.join(repo, 'first.txt'), 'first\n');
282
+ execFileSync('git', ['add', 'first.txt'], { cwd: repo });
283
+ execFileSync('git', ['commit', '-m', 'feat: already released'], { cwd: repo });
284
+ execFileSync('git', ['tag', 'v1.35.0'], { cwd: repo });
285
+ execFileSync('git', ['tag', '-f', 'newer-created-old-tag', 'old-tag'], { cwd: repo });
286
+ await writeFile(path.join(repo, 'second.txt'), 'second\n');
287
+ execFileSync('git', ['add', 'second.txt'], { cwd: repo });
288
+ execFileSync('git', ['commit', '-m', 'fix: unreleased change'], { cwd: repo });
289
+
290
+ await runRelease({
291
+ cwd: repo,
292
+ date: new Date('2026-05-29T12:00:00-07:00'),
293
+ });
294
+
295
+ const changelog = await readFile(path.join(repo, 'CHANGELOG.md'), 'utf8');
296
+ assert.match(changelog, /- fix: unreleased change/);
297
+ assert.doesNotMatch(changelog, /feat: already released/);
298
+ assert.doesNotMatch(changelog, /feat: initial app/);
299
+ });
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
+
268
355
  test('runRelease rolls back its release commit when tag creation fails', async () => {
269
356
  const repo = await makeRepo();
270
357
  execFileSync('git', ['tag', '26.0529'], { cwd: repo });
@@ -338,3 +425,9 @@ async function makeRepo({ packageLock = false } = {}) {
338
425
  execFileSync('git', ['commit', '-m', 'feat: initial app'], { cwd: repo });
339
426
  return repo;
340
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
+ }