calver-bump 0.1.1 → 0.1.3
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 +8 -8
- package/package.json +1 -1
- package/src/calver.js +16 -10
- package/src/index.js +6 -9
- package/test/calver.test.js +36 -9
- package/test/cli.test.js +5 -5
- package/test/release.test.js +47 -24
package/README.md
CHANGED
|
@@ -5,20 +5,20 @@ Release CLI for applications and internal tools that use readable CalVer version
|
|
|
5
5
|
Default version and tag format:
|
|
6
6
|
|
|
7
7
|
```text
|
|
8
|
-
YY.MMDD
|
|
8
|
+
YY.MMDD
|
|
9
9
|
```
|
|
10
10
|
|
|
11
11
|
Example:
|
|
12
12
|
|
|
13
13
|
```text
|
|
14
|
-
26.0529
|
|
14
|
+
26.0529
|
|
15
15
|
```
|
|
16
16
|
|
|
17
17
|
## What it does
|
|
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
|
|
|
@@ -91,15 +91,15 @@ Project defaults can be stored in `.calverbumprc.json`:
|
|
|
91
91
|
|
|
92
92
|
## Notes
|
|
93
93
|
|
|
94
|
-
- The default `short` format is `YY.MMDD.
|
|
95
|
-
- The optional `compact` format is `YYMMDD.
|
|
96
|
-
- The optional `long` format is `YYYY.MM.DD.
|
|
94
|
+
- The default `short` format is `YY.MMDD` for the first release of the day, then `YY.MMDD.1`, `YY.MMDD.2`, etc.
|
|
95
|
+
- The optional `compact` format is `YYMMDD` for the first release of the day, then `YYMMDD.1`, `YYMMDD.2`, etc.
|
|
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/calver.js
CHANGED
|
@@ -2,14 +2,20 @@ export function nextCalVer({ date = new Date(), existingTags = [], format = 'sho
|
|
|
2
2
|
assertFormat(format);
|
|
3
3
|
const parts = dateParts(date);
|
|
4
4
|
const prefix = calVerPrefix(parts, format);
|
|
5
|
-
const matcher = new RegExp(`^v?${escapeRegExp(prefix)}
|
|
6
|
-
const
|
|
5
|
+
const matcher = new RegExp(`^v?${escapeRegExp(prefix)}(?:\\.(\\d+))?$`);
|
|
6
|
+
const releaseState = existingTags.reduce((state, tag) => {
|
|
7
7
|
const match = matcher.exec(tag.trim());
|
|
8
|
-
if (!match) return
|
|
9
|
-
return
|
|
10
|
-
|
|
8
|
+
if (!match) return state;
|
|
9
|
+
return {
|
|
10
|
+
hasBase: state.hasBase || !match[1],
|
|
11
|
+
highestSequence: match[1] ? Math.max(state.highestSequence, Number(match[1])) : state.highestSequence,
|
|
12
|
+
};
|
|
13
|
+
}, { hasBase: false, highestSequence: 0 });
|
|
11
14
|
|
|
12
|
-
|
|
15
|
+
if (!releaseState.hasBase && releaseState.highestSequence === 0) {
|
|
16
|
+
return prefix;
|
|
17
|
+
}
|
|
18
|
+
return `${prefix}.${releaseState.highestSequence + 1}`;
|
|
13
19
|
}
|
|
14
20
|
|
|
15
21
|
export function assertFormat(format) {
|
|
@@ -19,10 +25,10 @@ export function assertFormat(format) {
|
|
|
19
25
|
}
|
|
20
26
|
|
|
21
27
|
export function isCalVerTag(tag) {
|
|
22
|
-
return /^v?\d{2}\.\d{4}
|
|
23
|
-
|| /^v?\d{6}
|
|
24
|
-
|| /^v?\d{4}\.\d{2}\.\d{2}
|
|
25
|
-
|| /^v?\d{8}
|
|
28
|
+
return /^v?\d{2}\.\d{4}(?:\.\d+)?$/.test(tag)
|
|
29
|
+
|| /^v?\d{6}(?:\.\d+)?$/.test(tag)
|
|
30
|
+
|| /^v?\d{4}\.\d{2}\.\d{2}(?:\.\d+)?$/.test(tag)
|
|
31
|
+
|| /^v?\d{8}(?:\.\d+)?$/.test(tag);
|
|
26
32
|
}
|
|
27
33
|
|
|
28
34
|
export function isoDate(date = new Date()) {
|
package/src/index.js
CHANGED
|
@@ -237,15 +237,12 @@ function parseGitRemote(remote) {
|
|
|
237
237
|
}
|
|
238
238
|
|
|
239
239
|
async function latestReachableTag(cwd) {
|
|
240
|
-
|
|
241
|
-
'
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
'refs/tags',
|
|
247
|
-
]);
|
|
248
|
-
return tags[0] ?? null;
|
|
240
|
+
try {
|
|
241
|
+
const { stdout } = await git(cwd, ['describe', '--tags', '--abbrev=0']);
|
|
242
|
+
return stdout.trim() || null;
|
|
243
|
+
} catch {
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
249
246
|
}
|
|
250
247
|
|
|
251
248
|
async function currentBranch(cwd) {
|
package/test/calver.test.js
CHANGED
|
@@ -3,40 +3,67 @@ import { test } from 'node:test';
|
|
|
3
3
|
|
|
4
4
|
import { nextCalVer } from '../src/calver.js';
|
|
5
5
|
|
|
6
|
-
test('nextCalVer defaults to readable YY.MMDD
|
|
6
|
+
test('nextCalVer defaults to readable YY.MMDD format for the first release of the day', () => {
|
|
7
7
|
const version = nextCalVer({
|
|
8
8
|
date: new Date('2026-05-29T12:00:00-07:00'),
|
|
9
9
|
existingTags: [],
|
|
10
10
|
});
|
|
11
11
|
|
|
12
|
-
assert.equal(version, '26.0529
|
|
12
|
+
assert.equal(version, '26.0529');
|
|
13
13
|
});
|
|
14
14
|
|
|
15
15
|
test('nextCalVer increments the sequence for existing tags on the same day', () => {
|
|
16
16
|
const version = nextCalVer({
|
|
17
17
|
date: new Date('2026-05-29T12:00:00-07:00'),
|
|
18
|
-
existingTags: ['26.0528.7', '26.0529
|
|
18
|
+
existingTags: ['26.0528.7', '26.0529', 'v26.0529.2'],
|
|
19
19
|
});
|
|
20
20
|
|
|
21
21
|
assert.equal(version, '26.0529.3');
|
|
22
22
|
});
|
|
23
23
|
|
|
24
|
-
test('nextCalVer
|
|
24
|
+
test('nextCalVer emits .1 for the second release of the day', () => {
|
|
25
|
+
const version = nextCalVer({
|
|
26
|
+
date: new Date('2026-05-29T12:00:00-07:00'),
|
|
27
|
+
existingTags: ['26.0529'],
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
assert.equal(version, '26.0529.1');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('nextCalVer can emit compact YYMMDD without sequence for the first release of the day', () => {
|
|
34
|
+
const version = nextCalVer({
|
|
35
|
+
date: new Date('2026-05-29T12:00:00-07:00'),
|
|
36
|
+
format: 'compact',
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
assert.equal(version, '260529');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('nextCalVer can emit compact YYMMDD.N after the first release of the day', () => {
|
|
25
43
|
const version = nextCalVer({
|
|
26
44
|
date: new Date('2026-05-29T12:00:00-07:00'),
|
|
27
|
-
existingTags: ['260529
|
|
45
|
+
existingTags: ['260529'],
|
|
28
46
|
format: 'compact',
|
|
29
47
|
});
|
|
30
48
|
|
|
31
|
-
assert.equal(version, '260529.
|
|
49
|
+
assert.equal(version, '260529.1');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('nextCalVer can emit long YYYY.MM.DD without sequence for the first release of the day', () => {
|
|
53
|
+
const version = nextCalVer({
|
|
54
|
+
date: new Date('2026-05-29T12:00:00-07:00'),
|
|
55
|
+
format: 'long',
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
assert.equal(version, '2026.05.29');
|
|
32
59
|
});
|
|
33
60
|
|
|
34
|
-
test('nextCalVer can emit long YYYY.MM.DD.N
|
|
61
|
+
test('nextCalVer can emit long YYYY.MM.DD.N after the first release of the day', () => {
|
|
35
62
|
const version = nextCalVer({
|
|
36
63
|
date: new Date('2026-05-29T12:00:00-07:00'),
|
|
37
|
-
existingTags: ['2026.05.29
|
|
64
|
+
existingTags: ['2026.05.29'],
|
|
38
65
|
format: 'long',
|
|
39
66
|
});
|
|
40
67
|
|
|
41
|
-
assert.equal(version, '2026.05.29.
|
|
68
|
+
assert.equal(version, '2026.05.29.1');
|
|
42
69
|
});
|
package/test/cli.test.js
CHANGED
|
@@ -45,12 +45,12 @@ test('CLI reads .calverbumprc.json defaults', async () => {
|
|
|
45
45
|
});
|
|
46
46
|
|
|
47
47
|
assert.equal(result.status, 0, result.stderr);
|
|
48
|
-
assert.match(result.stdout, /create git tag v26\.\d{4}
|
|
49
|
-
const tag = execFileSync('git', ['tag', '--list', 'v26
|
|
48
|
+
assert.match(result.stdout, /create git tag v26\.\d{4}/);
|
|
49
|
+
const tag = execFileSync('git', ['tag', '--list', 'v26*'], {
|
|
50
50
|
cwd: repo,
|
|
51
51
|
encoding: 'utf8',
|
|
52
52
|
}).trim();
|
|
53
|
-
assert.match(tag, /^v26\.\d{4}
|
|
53
|
+
assert.match(tag, /^v26\.\d{4}$/);
|
|
54
54
|
});
|
|
55
55
|
|
|
56
56
|
test('CLI prints and runs push command when --push is enabled', async () => {
|
|
@@ -66,11 +66,11 @@ test('CLI prints and runs push command when --push is enabled', async () => {
|
|
|
66
66
|
|
|
67
67
|
assert.equal(result.status, 0, result.stderr);
|
|
68
68
|
assert.match(result.stdout, /Running: git push --follow-tags origin main/);
|
|
69
|
-
const remoteTags = execFileSync('git', ['tag', '--list', '26
|
|
69
|
+
const remoteTags = execFileSync('git', ['tag', '--list', '26*'], {
|
|
70
70
|
cwd: remote,
|
|
71
71
|
encoding: 'utf8',
|
|
72
72
|
}).trim();
|
|
73
|
-
assert.match(remoteTags, /^26\.\d{4}
|
|
73
|
+
assert.match(remoteTags, /^26\.\d{4}$/);
|
|
74
74
|
});
|
|
75
75
|
|
|
76
76
|
test('CLI supports --remote with --push', async () => {
|
package/test/release.test.js
CHANGED
|
@@ -16,12 +16,12 @@ test('planRelease reports version, changelog, commit, and tag actions without wr
|
|
|
16
16
|
dryRun: true,
|
|
17
17
|
});
|
|
18
18
|
|
|
19
|
-
assert.equal(plan.version, '26.0529
|
|
19
|
+
assert.equal(plan.version, '26.0529');
|
|
20
20
|
assert.deepEqual(plan.actions, [
|
|
21
|
-
'update package.json version to 26.0529
|
|
22
|
-
'prepend CHANGELOG.md entry for 26.0529
|
|
23
|
-
'create git commit chore(release): 26.0529
|
|
24
|
-
'create git tag 26.0529
|
|
21
|
+
'update package.json version to 26.0529',
|
|
22
|
+
'prepend CHANGELOG.md entry for 26.0529',
|
|
23
|
+
'create git commit chore(release): 26.0529',
|
|
24
|
+
'create git tag 26.0529',
|
|
25
25
|
]);
|
|
26
26
|
|
|
27
27
|
const pkg = JSON.parse(await readFile(path.join(repo, 'package.json'), 'utf8'));
|
|
@@ -37,17 +37,17 @@ test('runRelease supports tag prefixes without changing package.json version', a
|
|
|
37
37
|
tagPrefix: 'v',
|
|
38
38
|
});
|
|
39
39
|
|
|
40
|
-
assert.equal(result.version, '26.0529
|
|
41
|
-
assert.equal(result.tag, 'v26.0529
|
|
40
|
+
assert.equal(result.version, '26.0529');
|
|
41
|
+
assert.equal(result.tag, 'v26.0529');
|
|
42
42
|
|
|
43
43
|
const pkg = JSON.parse(await readFile(path.join(repo, 'package.json'), 'utf8'));
|
|
44
|
-
assert.equal(pkg.version, '26.0529
|
|
44
|
+
assert.equal(pkg.version, '26.0529');
|
|
45
45
|
|
|
46
|
-
const tag = execFileSync('git', ['tag', '--list', 'v26.0529
|
|
46
|
+
const tag = execFileSync('git', ['tag', '--list', 'v26.0529'], {
|
|
47
47
|
cwd: repo,
|
|
48
48
|
encoding: 'utf8',
|
|
49
49
|
}).trim();
|
|
50
|
-
assert.equal(tag, 'v26.0529
|
|
50
|
+
assert.equal(tag, 'v26.0529');
|
|
51
51
|
});
|
|
52
52
|
|
|
53
53
|
test('runRelease updates package.json, prepends changelog, commits, and tags', async () => {
|
|
@@ -58,25 +58,25 @@ test('runRelease updates package.json, prepends changelog, commits, and tags', a
|
|
|
58
58
|
date: new Date('2026-05-29T12:00:00-07:00'),
|
|
59
59
|
});
|
|
60
60
|
|
|
61
|
-
assert.equal(result.version, '26.0529
|
|
61
|
+
assert.equal(result.version, '26.0529');
|
|
62
62
|
|
|
63
63
|
const pkg = JSON.parse(await readFile(path.join(repo, 'package.json'), 'utf8'));
|
|
64
|
-
assert.equal(pkg.version, '26.0529
|
|
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 - 2026-05-29\n\n### Features\n\n- feat: initial app/);
|
|
68
68
|
|
|
69
|
-
const tag = execFileSync('git', ['tag', '--list', '26.0529
|
|
69
|
+
const tag = execFileSync('git', ['tag', '--list', '26.0529'], {
|
|
70
70
|
cwd: repo,
|
|
71
71
|
encoding: 'utf8',
|
|
72
72
|
}).trim();
|
|
73
|
-
assert.equal(tag, '26.0529
|
|
73
|
+
assert.equal(tag, '26.0529');
|
|
74
74
|
|
|
75
75
|
const subject = execFileSync('git', ['log', '-1', '--pretty=%s'], {
|
|
76
76
|
cwd: repo,
|
|
77
77
|
encoding: 'utf8',
|
|
78
78
|
}).trim();
|
|
79
|
-
assert.equal(subject, 'chore(release): 26.0529
|
|
79
|
+
assert.equal(subject, 'chore(release): 26.0529');
|
|
80
80
|
});
|
|
81
81
|
|
|
82
82
|
test('runRelease returns the current branch for push guidance', async () => {
|
|
@@ -100,11 +100,11 @@ test('runRelease updates package-lock.json when it exists', async () => {
|
|
|
100
100
|
});
|
|
101
101
|
|
|
102
102
|
const lock = JSON.parse(await readFile(path.join(repo, 'package-lock.json'), 'utf8'));
|
|
103
|
-
assert.equal(lock.version, '26.0529
|
|
104
|
-
assert.equal(lock.packages[''].version, '26.0529
|
|
103
|
+
assert.equal(lock.version, '26.0529');
|
|
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
|
|
|
@@ -180,7 +180,7 @@ 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 - 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}\)/,
|
|
184
184
|
);
|
|
185
185
|
});
|
|
186
186
|
|
|
@@ -242,8 +242,8 @@ test('runRelease prepends only commits since the previous CalVer tag on later re
|
|
|
242
242
|
});
|
|
243
243
|
|
|
244
244
|
const changelog = await readFile(path.join(repo, 'CHANGELOG.md'), 'utf8');
|
|
245
|
-
const latestEntry = changelog.split('## 26.0529
|
|
246
|
-
assert.match(latestEntry, /## 26\.0529\.
|
|
245
|
+
const latestEntry = changelog.split('## 26.0529 - 2026-05-29')[0];
|
|
246
|
+
assert.match(latestEntry, /## 26\.0529\.1 - 2026-05-29/);
|
|
247
247
|
assert.match(latestEntry, /- fix: second release only/);
|
|
248
248
|
assert.doesNotMatch(latestEntry, /feat: initial app/);
|
|
249
249
|
});
|
|
@@ -265,9 +265,32 @@ test('runRelease uses the latest reachable tag as the changelog base even when i
|
|
|
265
265
|
assert.doesNotMatch(changelog, /- feat: initial app/);
|
|
266
266
|
});
|
|
267
267
|
|
|
268
|
+
test('runRelease uses the nearest history tag, not the newest-created reachable tag, as the changelog base', async () => {
|
|
269
|
+
const repo = await makeRepo();
|
|
270
|
+
execFileSync('git', ['tag', 'old-tag'], { cwd: repo });
|
|
271
|
+
await writeFile(path.join(repo, 'first.txt'), 'first\n');
|
|
272
|
+
execFileSync('git', ['add', 'first.txt'], { cwd: repo });
|
|
273
|
+
execFileSync('git', ['commit', '-m', 'feat: already released'], { cwd: repo });
|
|
274
|
+
execFileSync('git', ['tag', 'v1.35.0'], { cwd: repo });
|
|
275
|
+
execFileSync('git', ['tag', '-f', 'newer-created-old-tag', 'old-tag'], { cwd: repo });
|
|
276
|
+
await writeFile(path.join(repo, 'second.txt'), 'second\n');
|
|
277
|
+
execFileSync('git', ['add', 'second.txt'], { cwd: repo });
|
|
278
|
+
execFileSync('git', ['commit', '-m', 'fix: unreleased change'], { cwd: repo });
|
|
279
|
+
|
|
280
|
+
await runRelease({
|
|
281
|
+
cwd: repo,
|
|
282
|
+
date: new Date('2026-05-29T12:00:00-07:00'),
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
const changelog = await readFile(path.join(repo, 'CHANGELOG.md'), 'utf8');
|
|
286
|
+
assert.match(changelog, /- fix: unreleased change/);
|
|
287
|
+
assert.doesNotMatch(changelog, /feat: already released/);
|
|
288
|
+
assert.doesNotMatch(changelog, /feat: initial app/);
|
|
289
|
+
});
|
|
290
|
+
|
|
268
291
|
test('runRelease rolls back its release commit when tag creation fails', async () => {
|
|
269
292
|
const repo = await makeRepo();
|
|
270
|
-
execFileSync('git', ['tag', '26.0529
|
|
293
|
+
execFileSync('git', ['tag', '26.0529'], { cwd: repo });
|
|
271
294
|
const before = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: repo, encoding: 'utf8' }).trim();
|
|
272
295
|
|
|
273
296
|
await assert.rejects(
|