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 +1 -1
- package/src/index.js +62 -8
- package/test/release.test.js +70 -0
package/package.json
CHANGED
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
|
|
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
|
|
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
|
|
227
|
+
async function getRemoteUrl(cwd, remote) {
|
|
200
228
|
try {
|
|
201
|
-
const { stdout } = await git(cwd, ['remote', 'get-url',
|
|
202
|
-
return
|
|
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
|
|
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']);
|
package/test/release.test.js
CHANGED
|
@@ -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
|
+
}
|