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 +4 -1
- package/package.json +1 -1
- package/src/index.js +184 -16
- package/test/release.test.js +173 -4
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
|
|
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
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 {
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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('\
|
|
239
|
+
.split('\x1e')
|
|
192
240
|
.filter(Boolean)
|
|
193
|
-
.map((
|
|
194
|
-
const [hash, subject] =
|
|
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
|
|
247
|
+
async function getRemoteUrl(cwd, remote) {
|
|
200
248
|
try {
|
|
201
|
-
const { stdout } = await git(cwd, ['remote', 'get-url',
|
|
202
|
-
return
|
|
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
|
|
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']);
|
package/test/release.test.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
246
|
-
assert.match(latestEntry, /## 26\.0529\.1
|
|
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
|
+
}
|