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 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.N
8
+ YY.MMDD
9
9
  ```
10
10
 
11
11
  Example:
12
12
 
13
13
  ```text
14
- 26.0529.1
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 latest reachable tag.
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.N`.
95
- - The optional `compact` format is `YYMMDD.N`.
96
- - The optional `long` format is `YYYY.MM.DD.N`.
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 latest reachable tag, even when it is not a CalVer tag.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "calver-bump",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Release CLI for internal applications using readable CalVer versions.",
5
5
  "main": "src/index.js",
6
6
  "bin": {
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)}\\.(\\d+)$`);
6
- const highest = existingTags.reduce((max, tag) => {
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 max;
9
- return Math.max(max, Number(match[1]));
10
- }, 0);
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
- return `${prefix}.${highest + 1}`;
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}\.\d+$/.test(tag)
23
- || /^v?\d{6}\.\d+$/.test(tag)
24
- || /^v?\d{4}\.\d{2}\.\d{2}\.\d+$/.test(tag)
25
- || /^v?\d{8}\.\d+$/.test(tag);
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
- const tags = await gitLines(cwd, [
241
- 'for-each-ref',
242
- '--merged',
243
- 'HEAD',
244
- '--sort=-creatordate',
245
- '--format=%(refname:short)',
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) {
@@ -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.N format', () => {
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.1');
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.1', 'v26.0529.2'],
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 can emit compact YYMMDD.N when requested', () => {
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.1'],
45
+ existingTags: ['260529'],
28
46
  format: 'compact',
29
47
  });
30
48
 
31
- assert.equal(version, '260529.2');
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 when requested', () => {
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.1'],
64
+ existingTags: ['2026.05.29'],
38
65
  format: 'long',
39
66
  });
40
67
 
41
- assert.equal(version, '2026.05.29.2');
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}\.1/);
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}\.1$/);
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}\.1$/);
73
+ assert.match(remoteTags, /^26\.\d{4}$/);
74
74
  });
75
75
 
76
76
  test('CLI supports --remote with --push', async () => {
@@ -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.1');
19
+ assert.equal(plan.version, '26.0529');
20
20
  assert.deepEqual(plan.actions, [
21
- 'update package.json version to 26.0529.1',
22
- 'prepend CHANGELOG.md entry for 26.0529.1',
23
- 'create git commit chore(release): 26.0529.1',
24
- 'create git tag 26.0529.1',
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.1');
41
- assert.equal(result.tag, 'v26.0529.1');
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.1');
44
+ assert.equal(pkg.version, '26.0529');
45
45
 
46
- const tag = execFileSync('git', ['tag', '--list', 'v26.0529.1'], {
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.1');
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.1');
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.1');
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\.1 - 2026-05-29\n\n### Features\n\n- feat: initial app/);
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.1'], {
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.1');
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.1');
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.1');
104
- assert.equal(lock.packages[''].version, '26.0529.1');
103
+ assert.equal(lock.version, '26.0529');
104
+ assert.equal(lock.packages[''].version, '26.0529');
105
105
  });
106
106
 
107
- test('runRelease uses the latest reachable tag as the changelog base', async () => {
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.match(changelog, /- feat: after calver tag/);
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\.1 - 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 - 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.1 - 2026-05-29')[0];
246
- assert.match(latestEntry, /## 26\.0529\.2 - 2026-05-29/);
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.1'], { cwd: repo });
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(