calver-bump 0.1.3 → 0.1.5

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
@@ -94,11 +94,14 @@ Project defaults can be stored in `.calverbumprc.json`:
94
94
  - The default `short` format is `YY.MMDD` for the first release of the day, then `YY.MMDD.1`, `YY.MMDD.2`, etc.
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
+ - Changelog release headings use the CalVer version only and do not append a separate `YYYY-MM-DD` date.
97
98
  - Existing `v`-prefixed tags are considered when calculating the next sequence number.
98
99
  - Changelog ranges start from the nearest reachable tag, even when it is not a CalVer tag.
99
100
  - Changelog entries include conventional commit subjects only, such as `feat:`, `fix(scope):`, or `chore!:`.
100
101
  - Changelog entries are grouped into `Features`, `Fixes`, and `Other Changes`.
101
- - Changelog entries link to their commit hash for GitHub and GitLab-style `origin` remotes.
102
+ - Changelog entries link to GitHub pull requests or GitLab merge requests when the local git message includes references such as `#123`, `Merge pull request #123`, `!123`, or `See merge request group/project!123`.
103
+ - Changelog entries fall back to commit hash links for GitHub and GitLab-style remotes when no pull/merge request reference is found.
104
+ - Release entries include a `Full Changelog` section with a deduped list of pull/merge requests found in the release range, including the local commit title when available.
102
105
  - Later releases prepend only commits since the previous nearest reachable tag.
103
106
  - Release tags are annotated so `git push --follow-tags <remote> <branch>` pushes them.
104
107
  - The working tree must be clean before creating a real release.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "calver-bump",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
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
@@ -3,7 +3,7 @@ import { access, readFile, writeFile } from 'node:fs/promises';
3
3
  import path from 'node:path';
4
4
  import { promisify } from 'node:util';
5
5
 
6
- import { isoDate, nextCalVer } from './calver.js';
6
+ import { nextCalVer } from './calver.js';
7
7
 
8
8
  const execFile = promisify(execFileCallback);
9
9
 
@@ -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,15 @@ 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
+ previousTag: notes.previousTag,
133
+ tag: `${options.tagPrefix ?? ''}${version}`,
134
+ compareUrlBuilder: notes.compareUrlBuilder,
135
+ });
136
+ const entry = `${heading}\n\n${formatReleaseNotes(notes.changes)}${formatFullChangelog(notes.requests)}\n`;
137
+
131
138
  const body = existing.trim().startsWith('# Changelog')
132
139
  ? existing.replace(/^# Changelog\s*/, `# Changelog\n\n${entry}\n`)
133
140
  : `# Changelog\n\n${entry}\n${existing}`;
@@ -136,19 +143,43 @@ async function prependChangelog(cwd, version, date, options = {}) {
136
143
  }
137
144
 
138
145
  async function releaseNotes(cwd, options = {}) {
139
- const latestTag = await latestReachableTag(cwd);
146
+ await fetchTags(cwd, options.remote ?? 'origin');
147
+ const latestTag = await latestReleaseTag(cwd, options.existingChangelog ?? '');
140
148
  const range = latestTag ? [`${latestTag}..HEAD`] : [];
141
149
  const commits = await gitCommits(cwd, range);
142
- const commitUrlBuilder = await commitUrlBuilderForOrigin(cwd);
150
+ const remoteUrl = await getRemoteUrl(cwd, options.remote ?? 'origin');
151
+ const commitUrlBuilder = remoteUrl ? buildCommitUrlBuilder(remoteUrl) : null;
152
+ const compareUrlBuilder = remoteUrl ? buildCompareUrlBuilder(remoteUrl) : null;
153
+ const requestUrlBuilder = remoteUrl ? buildRequestUrlBuilder(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,
161
+ request: requestForCommit(commit, requestUrlBuilder),
149
162
  url: commitUrlBuilder ? commitUrlBuilder(commit.hash) : null,
150
163
  }));
151
- return conventionalCommits.length > 0 ? conventionalCommits : ['No conventional commits in this release.'];
164
+ const requests = uniqueRequests(commits.map((commit) => requestForCommit(commit, requestUrlBuilder)).filter(Boolean));
165
+ return {
166
+ previousTag: latestTag,
167
+ compareUrlBuilder,
168
+ changes: conventionalCommits.length > 0 ? conventionalCommits : ['No conventional commits in this release.'],
169
+ requests,
170
+ };
171
+ }
172
+
173
+ function formatReleaseHeading({ version, previousTag, tag, compareUrlBuilder }) {
174
+ const label = previousTag && compareUrlBuilder
175
+ ? `[${version}](${compareUrlBuilder(previousTag, tag)})`
176
+ : version;
177
+ return `## ${label}`;
178
+ }
179
+
180
+ function isCommitInChangelog(commit, changelog) {
181
+ if (!changelog) return false;
182
+ return changelog.includes(commit.hash) || changelog.includes(commit.hash.slice(0, 7));
152
183
  }
153
184
 
154
185
  function isConventionalCommit(subject) {
@@ -175,37 +206,54 @@ function formatReleaseNotes(changes) {
175
206
  .join('\n\n');
176
207
  }
177
208
 
209
+ function formatFullChangelog(requests) {
210
+ if (requests.length === 0) {
211
+ return '';
212
+ }
213
+ return `\n\n### Full Changelog\n\n${requests.map((request) => `- ${formatRequestEntry(request)}`).join('\n')}`;
214
+ }
215
+
178
216
  function conventionalType(subject) {
179
217
  return /^(?<type>[a-z]+)(\([^)]+\))?!?: .+/.exec(subject)?.groups.type;
180
218
  }
181
219
 
182
220
  function formatCommitEntry(commit) {
183
221
  const shortHash = commit.hash.slice(0, 7);
184
- const suffix = commit.url ? ` ([${shortHash}](${commit.url}))` : ` (${shortHash})`;
222
+ const suffix = commit.request
223
+ ? ` (${formatRequestLink(commit.request)})`
224
+ : commit.url ? ` ([${shortHash}](${commit.url}))` : ` (${shortHash})`;
185
225
  return `${commit.subject}${suffix}`;
186
226
  }
187
227
 
228
+ function formatRequestLink(request) {
229
+ return request.url ? `[${request.label}](${request.url})` : request.label;
230
+ }
231
+
232
+ function formatRequestEntry(request) {
233
+ return request.title ? `${formatRequestLink(request)} ${request.title}` : formatRequestLink(request);
234
+ }
235
+
188
236
  async function gitCommits(cwd, range) {
189
- const { stdout } = await git(cwd, ['log', '--pretty=format:%H%x00%s', ...range]);
237
+ const { stdout } = await git(cwd, ['log', '--pretty=format:%H%x00%s%x00%B%x1e', ...range]);
190
238
  return stdout
191
- .split('\n')
239
+ .split('\x1e')
192
240
  .filter(Boolean)
193
- .map((line) => {
194
- const [hash, subject] = line.split('\0');
195
- return { hash, subject };
241
+ .map((record) => {
242
+ const [hash, subject, body = ''] = record.replace(/^\n+|\n+$/g, '').split('\0');
243
+ return { hash, subject, body };
196
244
  });
197
245
  }
198
246
 
199
- async function commitUrlBuilderForOrigin(cwd) {
247
+ async function getRemoteUrl(cwd, remote) {
200
248
  try {
201
- const { stdout } = await git(cwd, ['remote', 'get-url', 'origin']);
202
- return commitUrlBuilder(stdout.trim());
249
+ const { stdout } = await git(cwd, ['remote', 'get-url', remote]);
250
+ return stdout.trim();
203
251
  } catch {
204
252
  return null;
205
253
  }
206
254
  }
207
255
 
208
- function commitUrlBuilder(remote) {
256
+ function buildCommitUrlBuilder(remote) {
209
257
  const parsed = parseGitRemote(remote);
210
258
  if (!parsed) {
211
259
  return null;
@@ -222,6 +270,95 @@ function commitUrlBuilder(remote) {
222
270
  return null;
223
271
  }
224
272
 
273
+ function buildCompareUrlBuilder(remote) {
274
+ const parsed = parseGitRemote(remote);
275
+ if (!parsed) {
276
+ return null;
277
+ }
278
+
279
+ const baseUrl = `https://${parsed.host}/${parsed.repo}`;
280
+ if (parsed.host === 'github.com') {
281
+ return (from, to) => `${baseUrl}/compare/${from}...${to}`;
282
+ }
283
+ if (parsed.host.includes('gitlab')) {
284
+ return (from, to) => `${baseUrl}/-/compare/${from}...${to}`;
285
+ }
286
+
287
+ return null;
288
+ }
289
+
290
+ function buildRequestUrlBuilder(remote) {
291
+ const parsed = parseGitRemote(remote);
292
+ if (!parsed) {
293
+ return null;
294
+ }
295
+
296
+ const baseUrl = `https://${parsed.host}/${parsed.repo}`;
297
+ if (parsed.host === 'github.com') {
298
+ return (request) => request.provider === 'github' ? `${baseUrl}/pull/${request.number}` : null;
299
+ }
300
+ if (parsed.host.includes('gitlab')) {
301
+ return (request) => request.provider === 'gitlab' ? `${baseUrl}/-/merge_requests/${request.number}` : null;
302
+ }
303
+
304
+ return null;
305
+ }
306
+
307
+ function requestForCommit(commit, requestUrlBuilder) {
308
+ const request = parseRequestReference(`${commit.subject}\n${commit.body ?? ''}`);
309
+ if (!request) {
310
+ return null;
311
+ }
312
+ return {
313
+ ...request,
314
+ title: requestTitleForCommit(commit),
315
+ url: requestUrlBuilder ? requestUrlBuilder(request) : null,
316
+ };
317
+ }
318
+
319
+ function requestTitleForCommit(commit) {
320
+ if (isConventionalCommit(commit.subject)) {
321
+ return commit.subject;
322
+ }
323
+
324
+ return (commit.body ?? '')
325
+ .split('\n')
326
+ .map((line) => line.trim())
327
+ .find((line) => line && !parseRequestReference(line) && !/^Merge\b/i.test(line)) ?? null;
328
+ }
329
+
330
+ function parseRequestReference(message) {
331
+ const gitlabMerge = /(?:^|\s)(?:See merge request\s+\S+!|!)(?<number>\d+)(?=\D|$)/i.exec(message);
332
+ if (gitlabMerge) {
333
+ return { provider: 'gitlab', number: gitlabMerge.groups.number, label: `!${gitlabMerge.groups.number}` };
334
+ }
335
+
336
+ const githubPull = /(?:Merge pull request\s+#|#)(?<number>\d+)(?=\D|$)/i.exec(message);
337
+ if (githubPull) {
338
+ return { provider: 'github', number: githubPull.groups.number, label: `#${githubPull.groups.number}` };
339
+ }
340
+
341
+ return null;
342
+ }
343
+
344
+ function uniqueRequests(requests) {
345
+ const seen = new Set();
346
+ const unique = [];
347
+ for (const request of requests) {
348
+ const key = `${request.provider}:${request.number}`;
349
+ if (seen.has(key)) {
350
+ const existing = unique.find((candidate) => `${candidate.provider}:${candidate.number}` === key);
351
+ if (existing && !existing.title && request.title) {
352
+ existing.title = request.title;
353
+ }
354
+ continue;
355
+ }
356
+ seen.add(key);
357
+ unique.push(request);
358
+ }
359
+ return unique;
360
+ }
361
+
225
362
  function parseGitRemote(remote) {
226
363
  const sshMatch = /^git@(?<host>[^:]+):(?<repo>.+?)(?:\.git)?$/.exec(remote);
227
364
  if (sshMatch) {
@@ -236,6 +373,28 @@ function parseGitRemote(remote) {
236
373
  return null;
237
374
  }
238
375
 
376
+ async function latestReleaseTag(cwd, changelog) {
377
+ const changelogTag = latestChangelogCompareTarget(changelog);
378
+ if (changelogTag && await tagExists(cwd, changelogTag)) {
379
+ return changelogTag;
380
+ }
381
+ return latestReachableTag(cwd);
382
+ }
383
+
384
+ function latestChangelogCompareTarget(changelog) {
385
+ const match = /^## \[[^\]]+\]\([^)]*\/(?:-\/)?compare\/[^)]*?\.{3}(?<tag>[^)\s]+)\)/m.exec(changelog);
386
+ return match?.groups.tag ?? null;
387
+ }
388
+
389
+ async function tagExists(cwd, tag) {
390
+ try {
391
+ await git(cwd, ['rev-parse', '--verify', '--quiet', `refs/tags/${tag}^{}`]);
392
+ return true;
393
+ } catch {
394
+ return false;
395
+ }
396
+ }
397
+
239
398
  async function latestReachableTag(cwd) {
240
399
  try {
241
400
  const { stdout } = await git(cwd, ['describe', '--tags', '--abbrev=0']);
@@ -245,6 +404,15 @@ async function latestReachableTag(cwd) {
245
404
  }
246
405
  }
247
406
 
407
+ async function fetchTags(cwd, remote) {
408
+ try {
409
+ await git(cwd, ['remote', 'get-url', remote]);
410
+ await git(cwd, ['fetch', '--tags', remote]);
411
+ } catch {
412
+ // Local/offline repos can still release using tags already present.
413
+ }
414
+ }
415
+
248
416
  async function currentBranch(cwd) {
249
417
  try {
250
418
  const { stdout } = await git(cwd, ['branch', '--show-current']);
@@ -64,7 +64,7 @@ test('runRelease updates package.json, prepends changelog, commits, and tags', a
64
64
  assert.equal(pkg.version, '26.0529');
65
65
 
66
66
  const changelog = await readFile(path.join(repo, 'CHANGELOG.md'), 'utf8');
67
- assert.match(changelog, /^# Changelog\n\n## 26\.0529 - 2026-05-29\n\n### Features\n\n- feat: initial app/);
67
+ assert.match(changelog, /^# Changelog\n\n## 26\.0529\n\n### Features\n\n- feat: initial app/);
68
68
 
69
69
  const tag = execFileSync('git', ['tag', '--list', '26.0529'], {
70
70
  cwd: repo,
@@ -180,13 +180,14 @@ test('runRelease groups changelog entries by conventional commit type', async ()
180
180
  const changelog = await readFile(path.join(repo, 'CHANGELOG.md'), 'utf8');
181
181
  assert.match(
182
182
  changelog,
183
- /## 26\.0529 - 2026-05-29\n\n### Features\n\n- feat: add release grouping \([a-f0-9]{7}\)\n- feat: initial app \([a-f0-9]{7}\)\n\n### Fixes\n\n- fix\(auth\): repair token refresh \([a-f0-9]{7}\)/,
183
+ /## 26\.0529\n\n### Features\n\n- feat: add release grouping \([a-f0-9]{7}\)\n- feat: initial app \([a-f0-9]{7}\)\n\n### Fixes\n\n- fix\(auth\): repair token refresh \([a-f0-9]{7}\)/,
184
184
  );
185
185
  });
186
186
 
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\)/,
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,75 @@ 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\)/,
236
+ );
237
+ });
238
+
239
+ test('runRelease links changelog entries to GitHub pull requests when commit subjects include PR numbers', async () => {
240
+ const repo = await makeRepo();
241
+ execFileSync('git', ['remote', 'add', 'origin', 'git@github.com:msako/demo-app.git'], { cwd: repo });
242
+ execFileSync('git', ['tag', 'v1.0.0'], { cwd: repo });
243
+ await writeFile(path.join(repo, 'feature.txt'), 'feature\n');
244
+ execFileSync('git', ['add', 'feature.txt'], { cwd: repo });
245
+ execFileSync('git', ['commit', '-m', 'feat: add pull request links (#42)'], { cwd: repo });
246
+
247
+ await runRelease({
248
+ cwd: repo,
249
+ date: new Date('2026-05-29T12:00:00-07:00'),
250
+ });
251
+
252
+ const changelog = await readFile(path.join(repo, 'CHANGELOG.md'), 'utf8');
253
+ assert.match(
254
+ changelog,
255
+ /- feat: add pull request links \(#42\) \(\[#42\]\(https:\/\/github\.com\/msako\/demo-app\/pull\/42\)\)/,
256
+ );
257
+ assert.match(
258
+ changelog,
259
+ /### Full Changelog\n\n- \[#42\]\(https:\/\/github\.com\/msako\/demo-app\/pull\/42\) feat: add pull request links \(#42\)/,
260
+ );
261
+ });
262
+
263
+ test('runRelease links changelog entries to GitLab merge requests and dedupes the full changelog', async () => {
264
+ const repo = await makeRepo();
265
+ execFileSync('git', ['remote', 'add', 'origin', 'git@gitlab.internal.example.com:platform/demo-app.git'], { cwd: repo });
266
+ execFileSync('git', ['tag', 'v1.0.0'], { cwd: repo });
267
+ await writeFile(path.join(repo, 'fix.txt'), 'fix\n');
268
+ execFileSync('git', ['add', 'fix.txt'], { cwd: repo });
269
+ execFileSync('git', [
270
+ 'commit',
271
+ '-m',
272
+ 'fix(review): block rule-listed reviewers',
273
+ '-m',
274
+ 'See merge request platform/demo-app!77',
275
+ ], { cwd: repo });
276
+ await writeFile(path.join(repo, 'merge.txt'), 'merge\n');
277
+ execFileSync('git', ['add', 'merge.txt'], { cwd: repo });
278
+ execFileSync('git', [
279
+ 'commit',
280
+ '-m',
281
+ 'Merge branch feature/review into main',
282
+ '-m',
283
+ 'See merge request platform/demo-app!77',
284
+ ], { cwd: repo });
285
+
286
+ await runRelease({
287
+ cwd: repo,
288
+ date: new Date('2026-05-29T12:00:00-07:00'),
289
+ });
290
+
291
+ const changelog = await readFile(path.join(repo, 'CHANGELOG.md'), 'utf8');
292
+ assert.match(
293
+ changelog,
294
+ /- fix\(review\): block rule-listed reviewers \(\[!77\]\(https:\/\/gitlab\.internal\.example\.com\/platform\/demo-app\/-\/merge_requests\/77\)\)/,
295
+ );
296
+ const fullChangelogEntries = changelog.match(/\[!77\]\(https:\/\/gitlab\.internal\.example\.com\/platform\/demo-app\/-\/merge_requests\/77\)/g) ?? [];
297
+ assert.equal(fullChangelogEntries.length, 2);
298
+ assert.match(
299
+ changelog,
300
+ /### Full Changelog\n\n- \[!77\]\(https:\/\/gitlab\.internal\.example\.com\/platform\/demo-app\/-\/merge_requests\/77\) fix\(review\): block rule-listed reviewers/,
301
+ );
227
302
  });
228
303
 
229
304
  test('runRelease prepends only commits since the previous CalVer tag on later releases', async () => {
@@ -242,8 +317,8 @@ test('runRelease prepends only commits since the previous CalVer tag on later re
242
317
  });
243
318
 
244
319
  const changelog = await readFile(path.join(repo, 'CHANGELOG.md'), 'utf8');
245
- const latestEntry = changelog.split('## 26.0529 - 2026-05-29')[0];
246
- assert.match(latestEntry, /## 26\.0529\.1 - 2026-05-29/);
320
+ const latestEntry = changelog.split('\n## 26.0529\n')[0];
321
+ assert.match(latestEntry, /## 26\.0529\.1/);
247
322
  assert.match(latestEntry, /- fix: second release only/);
248
323
  assert.doesNotMatch(latestEntry, /feat: initial app/);
249
324
  });
@@ -288,6 +363,94 @@ test('runRelease uses the nearest history tag, not the newest-created reachable
288
363
  assert.doesNotMatch(changelog, /feat: initial app/);
289
364
  });
290
365
 
366
+ test('runRelease fetches remote tags before choosing the changelog base', async () => {
367
+ const repo = await makeRepo();
368
+ const remote = await makeBareRepo();
369
+ execFileSync('git', ['remote', 'add', 'origin', remote], { cwd: repo });
370
+ await writeFile(path.join(repo, 'released.txt'), 'released\n');
371
+ execFileSync('git', ['add', 'released.txt'], { cwd: repo });
372
+ execFileSync('git', ['commit', '-m', 'feat: already in remote tagged release'], { cwd: repo });
373
+ execFileSync('git', ['tag', 'v1.35.0'], { cwd: repo });
374
+ execFileSync('git', ['push', 'origin', 'main', '--tags'], { cwd: repo });
375
+ execFileSync('git', ['tag', '-d', 'v1.35.0'], { cwd: repo });
376
+ await writeFile(
377
+ path.join(repo, 'CHANGELOG.md'),
378
+ '# 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',
379
+ );
380
+ await writeFile(path.join(repo, 'unreleased.txt'), 'unreleased\n');
381
+ execFileSync('git', ['add', 'CHANGELOG.md', 'unreleased.txt'], { cwd: repo });
382
+ execFileSync('git', ['commit', '-m', 'fix: unreleased after remote tag'], { cwd: repo });
383
+
384
+ await runRelease({
385
+ cwd: repo,
386
+ date: new Date('2026-06-02T12:00:00-07:00'),
387
+ });
388
+
389
+ const changelog = await readFile(path.join(repo, 'CHANGELOG.md'), 'utf8');
390
+ const latestEntry = changelog.split('## [1.35.0]')[0];
391
+ assert.match(latestEntry, /- fix: unreleased after remote tag/);
392
+ assert.doesNotMatch(latestEntry, /feat: already in remote tagged release/);
393
+ });
394
+
395
+ test('runRelease does not duplicate commits already present in an existing changelog', async () => {
396
+ const repo = await makeRepo();
397
+ await writeFile(path.join(repo, 'released.txt'), 'released\n');
398
+ execFileSync('git', ['add', 'released.txt'], { cwd: repo });
399
+ execFileSync('git', ['commit', '-m', 'feat: already documented'], { cwd: repo });
400
+ const documentedHash = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: repo, encoding: 'utf8' }).trim();
401
+ await writeFile(
402
+ path.join(repo, 'CHANGELOG.md'),
403
+ `# 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`,
404
+ );
405
+ await writeFile(path.join(repo, 'unreleased.txt'), 'unreleased\n');
406
+ execFileSync('git', ['add', 'CHANGELOG.md', 'unreleased.txt'], { cwd: repo });
407
+ execFileSync('git', ['commit', '-m', 'fix: not yet documented'], { cwd: repo });
408
+
409
+ await runRelease({
410
+ cwd: repo,
411
+ date: new Date('2026-06-02T12:00:00-07:00'),
412
+ });
413
+
414
+ const changelog = await readFile(path.join(repo, 'CHANGELOG.md'), 'utf8');
415
+ const latestEntry = changelog.split('## [1.35.0]')[0];
416
+ assert.match(latestEntry, /- fix: not yet documented/);
417
+ assert.doesNotMatch(latestEntry, /feat: already documented/);
418
+ });
419
+
420
+ test('runRelease uses the previous changelog compare target for the new compare link', async () => {
421
+ const repo = await makeRepo();
422
+ execFileSync('git', ['remote', 'add', 'origin', 'git@gitlab.ops:pss/d2pass/d2p_next.git'], { cwd: repo });
423
+ execFileSync('git', ['tag', 'v1.34.0'], { cwd: repo });
424
+ await writeFile(path.join(repo, 'released.txt'), 'released\n');
425
+ execFileSync('git', ['add', 'released.txt'], { cwd: repo });
426
+ execFileSync('git', ['commit', '-m', 'feat: already documented release'], { cwd: repo });
427
+ const releaseHead = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: repo, encoding: 'utf8' }).trim();
428
+ await writeFile(
429
+ path.join(repo, 'CHANGELOG.md'),
430
+ '# Changelog\n\n## [1.35.0](https://gitlab.ops/pss/d2pass/d2p_next/compare/v1.34.0...v1.35.0) (2026-06-01)\n\n### Features\n\n* **site-map:** already documented release\n',
431
+ );
432
+ await writeFile(path.join(repo, 'unreleased.txt'), 'unreleased\n');
433
+ execFileSync('git', ['add', 'CHANGELOG.md', 'unreleased.txt'], { cwd: repo });
434
+ execFileSync('git', ['commit', '-m', 'fix: new release change'], { cwd: repo });
435
+ execFileSync('git', ['tag', 'v1.35.0', releaseHead], { cwd: repo });
436
+
437
+ await runRelease({
438
+ cwd: repo,
439
+ date: new Date('2026-06-02T12:00:00-07:00'),
440
+ tagPrefix: 'v',
441
+ types: ['feat', 'fix'],
442
+ });
443
+
444
+ const changelog = await readFile(path.join(repo, 'CHANGELOG.md'), 'utf8');
445
+ assert.match(
446
+ changelog,
447
+ /^# Changelog\n\n## \[26\.0602\]\(https:\/\/gitlab\.ops\/pss\/d2pass\/d2p_next\/-\/compare\/v1\.35\.0\.\.\.v26\.0602\)/,
448
+ );
449
+ const latestEntry = changelog.split('## [1.35.0]')[0];
450
+ assert.match(latestEntry, /- fix: new release change/);
451
+ assert.doesNotMatch(latestEntry, /feat: already documented release/);
452
+ });
453
+
291
454
  test('runRelease rolls back its release commit when tag creation fails', async () => {
292
455
  const repo = await makeRepo();
293
456
  execFileSync('git', ['tag', '26.0529'], { cwd: repo });
@@ -361,3 +524,9 @@ async function makeRepo({ packageLock = false } = {}) {
361
524
  execFileSync('git', ['commit', '-m', 'feat: initial app'], { cwd: repo });
362
525
  return repo;
363
526
  }
527
+
528
+ async function makeBareRepo() {
529
+ const repo = await mkdtemp(path.join(tmpdir(), 'calver-bump-remote-'));
530
+ execFileSync('git', ['init', '--bare'], { cwd: repo });
531
+ return repo;
532
+ }