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 +3 -3
- package/package.json +1 -1
- package/src/index.js +68 -17
- package/test/release.test.js +95 -2
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
|
|
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
|
|
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
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) {
|
|
@@ -237,15 +282,21 @@ function parseGitRemote(remote) {
|
|
|
237
282
|
}
|
|
238
283
|
|
|
239
284
|
async function latestReachableTag(cwd) {
|
|
240
|
-
|
|
241
|
-
'
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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) {
|
package/test/release.test.js
CHANGED
|
@@ -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
|
|
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.
|
|
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
|
+
}
|