calver-bump 0.1.0 → 0.1.2

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,22 +5,22 @@ 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
- YYYY.MM.DD.N
8
+ YY.MMDD
9
9
  ```
10
10
 
11
11
  Example:
12
12
 
13
13
  ```text
14
- 2026.05.29.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 git commits since the last CalVer tag.
21
+ 3. Creates or prepends a `CHANGELOG.md` entry from conventional commits since the latest reachable tag.
22
22
  4. Creates a release commit.
23
- 5. Creates a git tag.
23
+ 5. Creates an annotated git tag.
24
24
 
25
25
  ## Usage
26
26
 
@@ -40,11 +40,66 @@ Use compact CalVer instead:
40
40
  npx calver-bump --format compact
41
41
  ```
42
42
 
43
+ Use long CalVer instead:
44
+
45
+ ```bash
46
+ npx calver-bump --format long
47
+ ```
48
+
49
+ Create a `v`-prefixed git tag while keeping `package.json` unprefixed:
50
+
51
+ ```bash
52
+ npx calver-bump --tag-prefix v
53
+ ```
54
+
55
+ Push the release commit and annotated tag:
56
+
57
+ ```bash
58
+ npx calver-bump --push
59
+ ```
60
+
61
+ Push to a different remote:
62
+
63
+ ```bash
64
+ npx calver-bump --push --remote upstream
65
+ ```
66
+
67
+ Only include selected conventional commit types:
68
+
69
+ ```bash
70
+ npx calver-bump --types feat,fix,perf
71
+ ```
72
+
73
+ Update files without creating a release commit or tag:
74
+
75
+ ```bash
76
+ npx calver-bump --skip-commit
77
+ ```
78
+
79
+ ## Configuration
80
+
81
+ Project defaults can be stored in `.calverbumprc.json`:
82
+
83
+ ```json
84
+ {
85
+ "format": "short",
86
+ "tagPrefix": "v",
87
+ "remote": "origin",
88
+ "types": ["feat", "fix", "perf"]
89
+ }
90
+ ```
91
+
43
92
  ## Notes
44
93
 
45
- - The default `dotted` format is `YYYY.MM.DD.N`.
46
- - The optional `compact` format is `YYYYMMDD.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.
47
97
  - Existing `v`-prefixed tags are considered when calculating the next sequence number.
48
- - Changelog ranges ignore non-CalVer tags.
98
+ - Changelog ranges start from the latest reachable tag, even when it is not a CalVer tag.
99
+ - Changelog entries include conventional commit subjects only, such as `feat:`, `fix(scope):`, or `chore!:`.
100
+ - Changelog entries are grouped into `Features`, `Fixes`, and `Other Changes`.
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.
103
+ - Release tags are annotated so `git push --follow-tags <remote> <branch>` pushes them.
49
104
  - The working tree must be clean before creating a real release.
50
105
  - If tag creation fails after the release commit, the CLI rolls back its own release commit.
@@ -1,29 +1,82 @@
1
1
  #!/usr/bin/env node
2
+ import { execFile as execFileCallback } from 'node:child_process';
3
+ import { readFile } from 'node:fs/promises';
4
+ import { promisify } from 'node:util';
5
+
2
6
  import { runRelease } from '../src/index.js';
3
7
  import { assertFormat } from '../src/calver.js';
4
8
 
9
+ const execFile = promisify(execFileCallback);
5
10
  const args = process.argv.slice(2);
6
- const dryRun = args.includes('--dry-run');
7
- const formatIndex = args.indexOf('--format');
8
- const format = formatIndex >= 0 ? args[formatIndex + 1] : 'dotted';
9
11
 
10
12
  try {
11
- assertFormat(format);
12
- const result = await runRelease({ dryRun, format });
13
+ const options = await releaseOptions(args);
14
+ assertFormat(options.format);
15
+ const result = await runRelease(options);
13
16
  console.log(`Release version: ${result.version}`);
14
17
  for (const action of result.actions) {
15
18
  console.log(`- ${action}`);
16
19
  }
17
- if (!dryRun) {
20
+ if (options.push && !options.dryRun) {
21
+ const pushArgs = ['push', '--follow-tags', result.remote, result.branch];
22
+ console.log('');
23
+ console.log(`Running: git ${pushArgs.join(' ')}`);
24
+ await execFile('git', pushArgs, { encoding: 'utf8' });
25
+ } else if (!options.dryRun && !options.skipCommit) {
18
26
  console.log('');
19
27
  console.log('Next steps:');
20
28
  console.log('1. Review the release commit:');
21
29
  console.log(' git show --stat HEAD');
22
30
  console.log('2. Push the release commit and tag:');
23
- console.log(` git push --follow-tags origin ${result.branch}`);
31
+ console.log(` git push --follow-tags ${result.remote} ${result.branch}`);
24
32
  console.log('3. Trigger or verify your deployment pipeline.');
25
33
  }
26
34
  } catch (error) {
27
35
  console.error(error.message);
28
36
  process.exitCode = 1;
29
37
  }
38
+
39
+ async function releaseOptions(args) {
40
+ const config = await readConfig();
41
+ return {
42
+ ...config,
43
+ dryRun: flag(args, '--dry-run') ?? config.dryRun ?? false,
44
+ push: flag(args, '--push') ?? config.push ?? false,
45
+ skipCommit: flag(args, '--skip-commit') ?? config.skipCommit ?? false,
46
+ format: value(args, '--format') ?? config.format ?? 'short',
47
+ remote: value(args, '--remote') ?? config.remote ?? 'origin',
48
+ tagPrefix: value(args, '--tag-prefix') ?? config.tagPrefix ?? '',
49
+ types: parseTypes(value(args, '--types')) ?? config.types,
50
+ };
51
+ }
52
+
53
+ async function readConfig() {
54
+ try {
55
+ return JSON.parse(await readFile('.calverbumprc.json', 'utf8'));
56
+ } catch (error) {
57
+ if (error.code === 'ENOENT') {
58
+ return {};
59
+ }
60
+ throw error;
61
+ }
62
+ }
63
+
64
+ function flag(args, name) {
65
+ return args.includes(name) ? true : undefined;
66
+ }
67
+
68
+ function value(args, name) {
69
+ const index = args.indexOf(name);
70
+ if (index < 0) {
71
+ return undefined;
72
+ }
73
+ const found = args[index + 1];
74
+ if (!found || found.startsWith('--')) {
75
+ throw new Error(`${name} requires a value.`);
76
+ }
77
+ return found;
78
+ }
79
+
80
+ function parseTypes(types) {
81
+ return types?.split(',').map((type) => type.trim()).filter(Boolean);
82
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "calver-bump",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
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
@@ -1,27 +1,34 @@
1
- export function nextCalVer({ date = new Date(), existingTags = [], format = 'dotted' } = {}) {
1
+ export function nextCalVer({ date = new Date(), existingTags = [], format = 'short' } = {}) {
2
2
  assertFormat(format);
3
3
  const parts = dateParts(date);
4
- const prefix = format === 'compact'
5
- ? `${parts.year}${parts.month}${parts.day}`
6
- : `${parts.year}.${parts.month}.${parts.day}`;
7
- const matcher = new RegExp(`^v?${escapeRegExp(prefix)}\\.(\\d+)$`);
8
- const highest = existingTags.reduce((max, tag) => {
4
+ const prefix = calVerPrefix(parts, format);
5
+ const matcher = new RegExp(`^v?${escapeRegExp(prefix)}(?:\\.(\\d+))?$`);
6
+ const releaseState = existingTags.reduce((state, tag) => {
9
7
  const match = matcher.exec(tag.trim());
10
- if (!match) return max;
11
- return Math.max(max, Number(match[1]));
12
- }, 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 });
13
14
 
14
- return `${prefix}.${highest + 1}`;
15
+ if (!releaseState.hasBase && releaseState.highestSequence === 0) {
16
+ return prefix;
17
+ }
18
+ return `${prefix}.${releaseState.highestSequence + 1}`;
15
19
  }
16
20
 
17
21
  export function assertFormat(format) {
18
- if (!['dotted', 'compact'].includes(format)) {
19
- throw new Error(`Invalid format "${format}". Expected "dotted" or "compact".`);
22
+ if (!['short', 'compact', 'long'].includes(format)) {
23
+ throw new Error(`Invalid format "${format}". Expected "short", "compact", or "long".`);
20
24
  }
21
25
  }
22
26
 
23
27
  export function isCalVerTag(tag) {
24
- return /^v?\d{4}\.\d{2}\.\d{2}\.\d+$/.test(tag) || /^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);
25
32
  }
26
33
 
27
34
  export function isoDate(date = new Date()) {
@@ -31,9 +38,20 @@ export function isoDate(date = new Date()) {
31
38
 
32
39
  function dateParts(date) {
33
40
  const year = String(date.getFullYear());
41
+ const shortYear = year.slice(-2);
34
42
  const month = String(date.getMonth() + 1).padStart(2, '0');
35
43
  const day = String(date.getDate()).padStart(2, '0');
36
- return { year, month, day };
44
+ return { year, shortYear, month, day };
45
+ }
46
+
47
+ function calVerPrefix(parts, format) {
48
+ if (format === 'compact') {
49
+ return `${parts.shortYear}${parts.month}${parts.day}`;
50
+ }
51
+ if (format === 'long') {
52
+ return `${parts.year}.${parts.month}.${parts.day}`;
53
+ }
54
+ return `${parts.shortYear}.${parts.month}${parts.day}`;
37
55
  }
38
56
 
39
57
  function escapeRegExp(value) {
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 { isoDate, isCalVerTag, nextCalVer } from './calver.js';
6
+ import { isoDate, nextCalVer } from './calver.js';
7
7
 
8
8
  const execFile = promisify(execFileCallback);
9
9
 
@@ -13,13 +13,16 @@ export async function planRelease(options = {}) {
13
13
  const version = nextCalVer({
14
14
  date: options.date,
15
15
  existingTags,
16
- format: options.format ?? 'dotted',
16
+ format: options.format ?? 'short',
17
17
  });
18
+ const tag = `${options.tagPrefix ?? ''}${version}`;
18
19
 
19
20
  return {
20
21
  version,
22
+ tag,
21
23
  branch: await currentBranch(cwd),
22
- actions: releaseActions(version),
24
+ remote: options.remote ?? 'origin',
25
+ actions: releaseActions({ version, tag, skipCommit: options.skipCommit }),
23
26
  };
24
27
  }
25
28
 
@@ -34,26 +37,34 @@ export async function runRelease(options = {}) {
34
37
  await assertCleanWorktree(cwd);
35
38
  await updatePackageVersion(cwd, plan.version);
36
39
  await updatePackageLock(cwd, plan.version);
37
- await prependChangelog(cwd, plan.version, options.date ?? new Date());
40
+ await prependChangelog(cwd, plan.version, options.date ?? new Date(), options);
41
+
42
+ if (options.skipCommit) {
43
+ return { ...plan, tag: null };
44
+ }
45
+
38
46
  await git(cwd, ['add', ...await releaseFiles(cwd)]);
39
47
  await git(cwd, ['commit', '-m', `chore(release): ${plan.version}`]);
40
48
  try {
41
- await git(cwd, ['tag', plan.version]);
49
+ await git(cwd, ['tag', '-a', plan.tag, '-m', `Release ${plan.version}`]);
42
50
  } catch (error) {
43
51
  await git(cwd, ['reset', '--hard', 'HEAD~1']);
44
- throw new Error(`Failed to create git tag ${plan.version}; rolled back release commit. ${error.message}`);
52
+ throw new Error(`Failed to create git tag ${plan.tag}; rolled back release commit. ${error.message}`);
45
53
  }
46
54
 
47
55
  return plan;
48
56
  }
49
57
 
50
- function releaseActions(version) {
51
- return [
58
+ function releaseActions({ version, tag, skipCommit = false }) {
59
+ const actions = [
52
60
  `update package.json version to ${version}`,
53
61
  `prepend CHANGELOG.md entry for ${version}`,
54
- `create git commit chore(release): ${version}`,
55
- `create git tag ${version}`,
56
62
  ];
63
+ if (!skipCommit) {
64
+ actions.push(`create git commit chore(release): ${version}`);
65
+ actions.push(`create git tag ${tag}`);
66
+ }
67
+ return actions;
57
68
  }
58
69
 
59
70
  async function updatePackageVersion(cwd, version) {
@@ -105,10 +116,10 @@ async function fileExists(filePath) {
105
116
  }
106
117
  }
107
118
 
108
- async function prependChangelog(cwd, version, date) {
119
+ async function prependChangelog(cwd, version, date, options = {}) {
109
120
  const changelogPath = path.join(cwd, 'CHANGELOG.md');
110
- const changes = await releaseNotes(cwd);
111
- const entry = `## ${version} - ${isoDate(date)}\n\n${changes.map((change) => `- ${change}`).join('\n')}\n`;
121
+ const changes = await releaseNotes(cwd, options);
122
+ const entry = `## ${version} - ${isoDate(date)}\n\n${formatReleaseNotes(changes)}\n`;
112
123
  let existing = '';
113
124
 
114
125
  try {
@@ -124,11 +135,105 @@ async function prependChangelog(cwd, version, date) {
124
135
  await writeFile(changelogPath, body);
125
136
  }
126
137
 
127
- async function releaseNotes(cwd) {
138
+ async function releaseNotes(cwd, options = {}) {
128
139
  const latestTag = await latestReachableTag(cwd);
129
140
  const range = latestTag ? [`${latestTag}..HEAD`] : [];
130
- const lines = await gitLines(cwd, ['log', '--pretty=%s', ...range]);
131
- return lines.length > 0 ? lines : ['Initial internal release.'];
141
+ const commits = await gitCommits(cwd, range);
142
+ const commitUrlBuilder = await commitUrlBuilderForOrigin(cwd);
143
+ const allowedTypes = options.types ?? ['feat', 'fix', 'docs', 'style', 'refactor', 'perf', 'test', 'build', 'ci', 'chore', 'revert'];
144
+ const conventionalCommits = commits
145
+ .filter((commit) => isConventionalCommit(commit.subject))
146
+ .filter((commit) => allowedTypes.includes(conventionalType(commit.subject)))
147
+ .map((commit) => ({
148
+ ...commit,
149
+ url: commitUrlBuilder ? commitUrlBuilder(commit.hash) : null,
150
+ }));
151
+ return conventionalCommits.length > 0 ? conventionalCommits : ['No conventional commits in this release.'];
152
+ }
153
+
154
+ function isConventionalCommit(subject) {
155
+ return /^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\([^)]+\))?!?: .+/.test(subject);
156
+ }
157
+
158
+ function formatReleaseNotes(changes) {
159
+ if (changes.length === 1 && changes[0] === 'No conventional commits in this release.') {
160
+ return `- ${changes[0]}`;
161
+ }
162
+
163
+ const features = changes.filter((change) => conventionalType(change.subject) === 'feat');
164
+ const fixes = changes.filter((change) => conventionalType(change.subject) === 'fix');
165
+ const other = changes.filter((change) => !['feat', 'fix'].includes(conventionalType(change.subject)));
166
+ const sections = [
167
+ ['Features', features],
168
+ ['Fixes', fixes],
169
+ ['Other Changes', other],
170
+ ];
171
+
172
+ return sections
173
+ .filter(([, entries]) => entries.length > 0)
174
+ .map(([heading, entries]) => `### ${heading}\n\n${entries.map((entry) => `- ${formatCommitEntry(entry)}`).join('\n')}`)
175
+ .join('\n\n');
176
+ }
177
+
178
+ function conventionalType(subject) {
179
+ return /^(?<type>[a-z]+)(\([^)]+\))?!?: .+/.exec(subject)?.groups.type;
180
+ }
181
+
182
+ function formatCommitEntry(commit) {
183
+ const shortHash = commit.hash.slice(0, 7);
184
+ const suffix = commit.url ? ` ([${shortHash}](${commit.url}))` : ` (${shortHash})`;
185
+ return `${commit.subject}${suffix}`;
186
+ }
187
+
188
+ async function gitCommits(cwd, range) {
189
+ const { stdout } = await git(cwd, ['log', '--pretty=format:%H%x00%s', ...range]);
190
+ return stdout
191
+ .split('\n')
192
+ .filter(Boolean)
193
+ .map((line) => {
194
+ const [hash, subject] = line.split('\0');
195
+ return { hash, subject };
196
+ });
197
+ }
198
+
199
+ async function commitUrlBuilderForOrigin(cwd) {
200
+ try {
201
+ const { stdout } = await git(cwd, ['remote', 'get-url', 'origin']);
202
+ return commitUrlBuilder(stdout.trim());
203
+ } catch {
204
+ return null;
205
+ }
206
+ }
207
+
208
+ function commitUrlBuilder(remote) {
209
+ const parsed = parseGitRemote(remote);
210
+ if (!parsed) {
211
+ return null;
212
+ }
213
+
214
+ const baseUrl = `https://${parsed.host}/${parsed.repo}`;
215
+ if (parsed.host === 'github.com') {
216
+ return (hash) => `${baseUrl}/commit/${hash}`;
217
+ }
218
+ if (parsed.host.includes('gitlab')) {
219
+ return (hash) => `${baseUrl}/-/commit/${hash}`;
220
+ }
221
+
222
+ return null;
223
+ }
224
+
225
+ function parseGitRemote(remote) {
226
+ const sshMatch = /^git@(?<host>[^:]+):(?<repo>.+?)(?:\.git)?$/.exec(remote);
227
+ if (sshMatch) {
228
+ return sshMatch.groups;
229
+ }
230
+
231
+ const httpsMatch = /^https:\/\/(?<host>[^/]+)\/(?<repo>.+?)(?:\.git)?$/.exec(remote);
232
+ if (httpsMatch) {
233
+ return httpsMatch.groups;
234
+ }
235
+
236
+ return null;
132
237
  }
133
238
 
134
239
  async function latestReachableTag(cwd) {
@@ -140,7 +245,7 @@ async function latestReachableTag(cwd) {
140
245
  '--format=%(refname:short)',
141
246
  'refs/tags',
142
247
  ]);
143
- return tags.find(isCalVerTag) ?? null;
248
+ return tags[0] ?? null;
144
249
  }
145
250
 
146
251
  async function currentBranch(cwd) {
@@ -3,30 +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 dotted format YYYY.MM.DD.N', () => {
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, '2026.05.29.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: ['2026.05.28.7', '2026.05.29.1', 'v2026.05.29.2'],
18
+ existingTags: ['26.0528.7', '26.0529', 'v26.0529.2'],
19
+ });
20
+
21
+ assert.equal(version, '26.0529.3');
22
+ });
23
+
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',
19
37
  });
20
38
 
21
- assert.equal(version, '2026.05.29.3');
39
+ assert.equal(version, '260529');
22
40
  });
23
41
 
24
- test('nextCalVer can emit compact YYYYMMDD.N when requested', () => {
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: ['20260529.1'],
45
+ existingTags: ['260529'],
28
46
  format: 'compact',
29
47
  });
30
48
 
31
- assert.equal(version, '20260529.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');
59
+ });
60
+
61
+ test('nextCalVer can emit long YYYY.MM.DD.N after the first release of the day', () => {
62
+ const version = nextCalVer({
63
+ date: new Date('2026-05-29T12:00:00-07:00'),
64
+ existingTags: ['2026.05.29'],
65
+ format: 'long',
66
+ });
67
+
68
+ assert.equal(version, '2026.05.29.1');
32
69
  });
package/test/cli.test.js CHANGED
@@ -29,6 +29,65 @@ test('CLI explains how to push the release commit and tag after a real release',
29
29
  assert.match(result.stdout, /git push --follow-tags origin main/);
30
30
  });
31
31
 
32
+ test('CLI reads .calverbumprc.json defaults', async () => {
33
+ const repo = await makeRepo();
34
+ await writeFile(
35
+ path.join(repo, '.calverbumprc.json'),
36
+ `${JSON.stringify({ tagPrefix: 'v', types: ['feat'] }, null, 2)}\n`,
37
+ );
38
+ execFileSync('git', ['add', '.calverbumprc.json'], { cwd: repo });
39
+ execFileSync('git', ['commit', '-m', 'chore: add calver bump config'], { cwd: repo });
40
+ const cliPath = path.resolve('bin/calver-bump.js');
41
+
42
+ const result = spawnSync(process.execPath, [cliPath], {
43
+ cwd: repo,
44
+ encoding: 'utf8',
45
+ });
46
+
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*'], {
50
+ cwd: repo,
51
+ encoding: 'utf8',
52
+ }).trim();
53
+ assert.match(tag, /^v26\.\d{4}$/);
54
+ });
55
+
56
+ test('CLI prints and runs push command when --push is enabled', async () => {
57
+ const remote = await makeBareRepo();
58
+ const repo = await makeRepo();
59
+ execFileSync('git', ['remote', 'add', 'origin', remote], { cwd: repo });
60
+ const cliPath = path.resolve('bin/calver-bump.js');
61
+
62
+ const result = spawnSync(process.execPath, [cliPath, '--push'], {
63
+ cwd: repo,
64
+ encoding: 'utf8',
65
+ });
66
+
67
+ assert.equal(result.status, 0, result.stderr);
68
+ assert.match(result.stdout, /Running: git push --follow-tags origin main/);
69
+ const remoteTags = execFileSync('git', ['tag', '--list', '26*'], {
70
+ cwd: remote,
71
+ encoding: 'utf8',
72
+ }).trim();
73
+ assert.match(remoteTags, /^26\.\d{4}$/);
74
+ });
75
+
76
+ test('CLI supports --remote with --push', async () => {
77
+ const remote = await makeBareRepo();
78
+ const repo = await makeRepo();
79
+ execFileSync('git', ['remote', 'add', 'upstream', remote], { cwd: repo });
80
+ const cliPath = path.resolve('bin/calver-bump.js');
81
+
82
+ const result = spawnSync(process.execPath, [cliPath, '--push', '--remote', 'upstream'], {
83
+ cwd: repo,
84
+ encoding: 'utf8',
85
+ });
86
+
87
+ assert.equal(result.status, 0, result.stderr);
88
+ assert.match(result.stdout, /Running: git push --follow-tags upstream main/);
89
+ });
90
+
32
91
  async function makeRepo() {
33
92
  const repo = await mkdtemp(path.join(tmpdir(), 'calver-bump-cli-'));
34
93
  execFileSync('git', ['init'], { cwd: repo });
@@ -42,3 +101,9 @@ async function makeRepo() {
42
101
  execFileSync('git', ['commit', '-m', 'feat: initial app'], { cwd: repo });
43
102
  return repo;
44
103
  }
104
+
105
+ async function makeBareRepo() {
106
+ const repo = await mkdtemp(path.join(tmpdir(), 'calver-bump-remote-'));
107
+ execFileSync('git', ['init', '--bare'], { cwd: repo });
108
+ return repo;
109
+ }
@@ -16,18 +16,40 @@ test('planRelease reports version, changelog, commit, and tag actions without wr
16
16
  dryRun: true,
17
17
  });
18
18
 
19
- assert.equal(plan.version, '2026.05.29.1');
19
+ assert.equal(plan.version, '26.0529');
20
20
  assert.deepEqual(plan.actions, [
21
- 'update package.json version to 2026.05.29.1',
22
- 'prepend CHANGELOG.md entry for 2026.05.29.1',
23
- 'create git commit chore(release): 2026.05.29.1',
24
- 'create git tag 2026.05.29.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'));
28
28
  assert.equal(pkg.version, '0.0.0');
29
29
  });
30
30
 
31
+ test('runRelease supports tag prefixes without changing package.json version', async () => {
32
+ const repo = await makeRepo();
33
+
34
+ const result = await runRelease({
35
+ cwd: repo,
36
+ date: new Date('2026-05-29T12:00:00-07:00'),
37
+ tagPrefix: 'v',
38
+ });
39
+
40
+ assert.equal(result.version, '26.0529');
41
+ assert.equal(result.tag, 'v26.0529');
42
+
43
+ const pkg = JSON.parse(await readFile(path.join(repo, 'package.json'), 'utf8'));
44
+ assert.equal(pkg.version, '26.0529');
45
+
46
+ const tag = execFileSync('git', ['tag', '--list', 'v26.0529'], {
47
+ cwd: repo,
48
+ encoding: 'utf8',
49
+ }).trim();
50
+ assert.equal(tag, 'v26.0529');
51
+ });
52
+
31
53
  test('runRelease updates package.json, prepends changelog, commits, and tags', async () => {
32
54
  const repo = await makeRepo();
33
55
 
@@ -36,25 +58,25 @@ test('runRelease updates package.json, prepends changelog, commits, and tags', a
36
58
  date: new Date('2026-05-29T12:00:00-07:00'),
37
59
  });
38
60
 
39
- assert.equal(result.version, '2026.05.29.1');
61
+ assert.equal(result.version, '26.0529');
40
62
 
41
63
  const pkg = JSON.parse(await readFile(path.join(repo, 'package.json'), 'utf8'));
42
- assert.equal(pkg.version, '2026.05.29.1');
64
+ assert.equal(pkg.version, '26.0529');
43
65
 
44
66
  const changelog = await readFile(path.join(repo, 'CHANGELOG.md'), 'utf8');
45
- assert.match(changelog, /^# Changelog\n\n## 2026\.05\.29\.1 - 2026-05-29\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/);
46
68
 
47
- const tag = execFileSync('git', ['tag', '--list', '2026.05.29.1'], {
69
+ const tag = execFileSync('git', ['tag', '--list', '26.0529'], {
48
70
  cwd: repo,
49
71
  encoding: 'utf8',
50
72
  }).trim();
51
- assert.equal(tag, '2026.05.29.1');
73
+ assert.equal(tag, '26.0529');
52
74
 
53
75
  const subject = execFileSync('git', ['log', '-1', '--pretty=%s'], {
54
76
  cwd: repo,
55
77
  encoding: 'utf8',
56
78
  }).trim();
57
- assert.equal(subject, 'chore(release): 2026.05.29.1');
79
+ assert.equal(subject, 'chore(release): 26.0529');
58
80
  });
59
81
 
60
82
  test('runRelease returns the current branch for push guidance', async () => {
@@ -78,13 +100,13 @@ test('runRelease updates package-lock.json when it exists', async () => {
78
100
  });
79
101
 
80
102
  const lock = JSON.parse(await readFile(path.join(repo, 'package-lock.json'), 'utf8'));
81
- assert.equal(lock.version, '2026.05.29.1');
82
- assert.equal(lock.packages[''].version, '2026.05.29.1');
103
+ assert.equal(lock.version, '26.0529');
104
+ assert.equal(lock.packages[''].version, '26.0529');
83
105
  });
84
106
 
85
- test('runRelease uses the latest CalVer tag as the changelog base and ignores other tags', async () => {
107
+ test('runRelease uses the latest reachable tag as the changelog base', async () => {
86
108
  const repo = await makeRepo();
87
- execFileSync('git', ['tag', '2026.05.28.1'], { cwd: repo });
109
+ execFileSync('git', ['tag', '26.0528.1'], { cwd: repo });
88
110
  await writeFile(path.join(repo, 'feature-a.txt'), 'a\n');
89
111
  execFileSync('git', ['add', 'feature-a.txt'], { cwd: repo });
90
112
  execFileSync('git', ['commit', '-m', 'feat: after calver tag'], { cwd: repo });
@@ -104,9 +126,148 @@ test('runRelease uses the latest CalVer tag as the changelog base and ignores ot
104
126
  assert.doesNotMatch(changelog, /- feat: initial app/);
105
127
  });
106
128
 
129
+ test('runRelease includes only conventional commits in the changelog', async () => {
130
+ const repo = await makeRepo();
131
+ await writeFile(path.join(repo, 'note.txt'), 'note\n');
132
+ execFileSync('git', ['add', 'note.txt'], { cwd: repo });
133
+ execFileSync('git', ['commit', '-m', 'update docs manually'], { cwd: repo });
134
+ await writeFile(path.join(repo, 'fix.txt'), 'fix\n');
135
+ execFileSync('git', ['add', 'fix.txt'], { cwd: repo });
136
+ execFileSync('git', ['commit', '-m', 'fix(release): keep only conventional commits'], { cwd: repo });
137
+
138
+ await runRelease({
139
+ cwd: repo,
140
+ date: new Date('2026-05-29T12:00:00-07:00'),
141
+ });
142
+
143
+ const changelog = await readFile(path.join(repo, 'CHANGELOG.md'), 'utf8');
144
+ assert.match(changelog, /- fix\(release\): keep only conventional commits/);
145
+ assert.match(changelog, /- feat: initial app/);
146
+ assert.doesNotMatch(changelog, /update docs manually/);
147
+ });
148
+
149
+ test('runRelease filters changelog entries by configured conventional commit types', async () => {
150
+ const repo = await makeRepo();
151
+ await writeFile(path.join(repo, 'fix.txt'), 'fix\n');
152
+ execFileSync('git', ['add', 'fix.txt'], { cwd: repo });
153
+ execFileSync('git', ['commit', '-m', 'fix: excluded by type filter'], { cwd: repo });
154
+
155
+ await runRelease({
156
+ cwd: repo,
157
+ date: new Date('2026-05-29T12:00:00-07:00'),
158
+ types: ['feat'],
159
+ });
160
+
161
+ const changelog = await readFile(path.join(repo, 'CHANGELOG.md'), 'utf8');
162
+ assert.match(changelog, /- feat: initial app/);
163
+ assert.doesNotMatch(changelog, /fix: excluded by type filter/);
164
+ });
165
+
166
+ test('runRelease groups changelog entries by conventional commit type', async () => {
167
+ const repo = await makeRepo();
168
+ await writeFile(path.join(repo, 'fix.txt'), 'fix\n');
169
+ execFileSync('git', ['add', 'fix.txt'], { cwd: repo });
170
+ execFileSync('git', ['commit', '-m', 'fix(auth): repair token refresh'], { cwd: repo });
171
+ await writeFile(path.join(repo, 'feature.txt'), 'feature\n');
172
+ execFileSync('git', ['add', 'feature.txt'], { cwd: repo });
173
+ execFileSync('git', ['commit', '-m', 'feat: add release grouping'], { cwd: repo });
174
+
175
+ await runRelease({
176
+ cwd: repo,
177
+ date: new Date('2026-05-29T12:00:00-07:00'),
178
+ });
179
+
180
+ const changelog = await readFile(path.join(repo, 'CHANGELOG.md'), 'utf8');
181
+ assert.match(
182
+ changelog,
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
+ );
185
+ });
186
+
187
+ test('runRelease links each changelog entry to its commit on GitHub', async () => {
188
+ const repo = await makeRepo();
189
+ execFileSync('git', ['remote', 'add', 'origin', 'git@github.com:msako/demo-app.git'], { cwd: repo });
190
+ await writeFile(path.join(repo, 'feature.txt'), 'feature\n');
191
+ execFileSync('git', ['add', 'feature.txt'], { cwd: repo });
192
+ execFileSync('git', ['commit', '-m', 'feat: add linked changelog entry'], { cwd: repo });
193
+ const hash = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: repo, encoding: 'utf8' }).trim();
194
+ const shortHash = hash.slice(0, 7);
195
+
196
+ await runRelease({
197
+ cwd: repo,
198
+ date: new Date('2026-05-29T12:00:00-07:00'),
199
+ });
200
+
201
+ const changelog = await readFile(path.join(repo, 'CHANGELOG.md'), 'utf8');
202
+ assert.match(
203
+ changelog,
204
+ new RegExp(`- feat: add linked changelog entry \\(\\[${shortHash}\\]\\(https://github\\.com/msako/demo-app/commit/${hash}\\)\\)`),
205
+ );
206
+ });
207
+
208
+ test('runRelease links changelog entries for private GitLab-style remotes', async () => {
209
+ const repo = await makeRepo();
210
+ execFileSync('git', ['remote', 'add', 'origin', 'git@gitlab.internal.example.com:platform/demo-app.git'], { cwd: repo });
211
+ await writeFile(path.join(repo, 'feature.txt'), 'feature\n');
212
+ execFileSync('git', ['add', 'feature.txt'], { cwd: repo });
213
+ execFileSync('git', ['commit', '-m', 'fix: link private gitlab commit'], { cwd: repo });
214
+ const hash = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: repo, encoding: 'utf8' }).trim();
215
+ const shortHash = hash.slice(0, 7);
216
+
217
+ await runRelease({
218
+ cwd: repo,
219
+ date: new Date('2026-05-29T12:00:00-07:00'),
220
+ });
221
+
222
+ const changelog = await readFile(path.join(repo, 'CHANGELOG.md'), 'utf8');
223
+ assert.match(
224
+ changelog,
225
+ new RegExp(`- fix: link private gitlab commit \\(\\[${shortHash}\\]\\(https://gitlab\\.internal\\.example\\.com/platform/demo-app/-/commit/${hash}\\)\\)`),
226
+ );
227
+ });
228
+
229
+ test('runRelease prepends only commits since the previous CalVer tag on later releases', async () => {
230
+ const repo = await makeRepo();
231
+ await runRelease({
232
+ cwd: repo,
233
+ date: new Date('2026-05-29T12:00:00-07:00'),
234
+ });
235
+ await writeFile(path.join(repo, 'second.txt'), 'second\n');
236
+ execFileSync('git', ['add', 'second.txt'], { cwd: repo });
237
+ execFileSync('git', ['commit', '-m', 'fix: second release only'], { cwd: repo });
238
+
239
+ await runRelease({
240
+ cwd: repo,
241
+ date: new Date('2026-05-29T13:00:00-07:00'),
242
+ });
243
+
244
+ const changelog = await readFile(path.join(repo, 'CHANGELOG.md'), 'utf8');
245
+ const latestEntry = changelog.split('## 26.0529 - 2026-05-29')[0];
246
+ assert.match(latestEntry, /## 26\.0529\.1 - 2026-05-29/);
247
+ assert.match(latestEntry, /- fix: second release only/);
248
+ assert.doesNotMatch(latestEntry, /feat: initial app/);
249
+ });
250
+
251
+ test('runRelease uses the latest reachable tag as the changelog base even when it is not CalVer', async () => {
252
+ const repo = await makeRepo();
253
+ execFileSync('git', ['tag', 'v2.20'], { cwd: repo });
254
+ await writeFile(path.join(repo, 'later.txt'), 'later\n');
255
+ execFileSync('git', ['add', 'later.txt'], { cwd: repo });
256
+ execFileSync('git', ['commit', '-m', 'fix: after legacy version tag'], { cwd: repo });
257
+
258
+ await runRelease({
259
+ cwd: repo,
260
+ date: new Date('2026-05-29T12:00:00-07:00'),
261
+ });
262
+
263
+ const changelog = await readFile(path.join(repo, 'CHANGELOG.md'), 'utf8');
264
+ assert.match(changelog, /- fix: after legacy version tag/);
265
+ assert.doesNotMatch(changelog, /- feat: initial app/);
266
+ });
267
+
107
268
  test('runRelease rolls back its release commit when tag creation fails', async () => {
108
269
  const repo = await makeRepo();
109
- execFileSync('git', ['tag', '2026.05.29.1'], { cwd: repo });
270
+ execFileSync('git', ['tag', '26.0529'], { cwd: repo });
110
271
  const before = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: repo, encoding: 'utf8' }).trim();
111
272
 
112
273
  await assert.rejects(
@@ -122,6 +283,30 @@ test('runRelease rolls back its release commit when tag creation fails', async (
122
283
  assert.equal(after, before);
123
284
  });
124
285
 
286
+ test('runRelease can skip commit and tag creation', async () => {
287
+ const repo = await makeRepo();
288
+
289
+ const result = await runRelease({
290
+ cwd: repo,
291
+ date: new Date('2026-05-29T12:00:00-07:00'),
292
+ skipCommit: true,
293
+ });
294
+
295
+ assert.equal(result.tag, null);
296
+ const subject = execFileSync('git', ['log', '-1', '--pretty=%s'], {
297
+ cwd: repo,
298
+ encoding: 'utf8',
299
+ }).trim();
300
+ assert.equal(subject, 'feat: initial app');
301
+
302
+ const status = execFileSync('git', ['status', '--porcelain'], {
303
+ cwd: repo,
304
+ encoding: 'utf8',
305
+ }).trim();
306
+ assert.match(status, /M package\.json/);
307
+ assert.match(status, /\?\? CHANGELOG\.md/);
308
+ });
309
+
125
310
  async function makeRepo({ packageLock = false } = {}) {
126
311
  const repo = await mkdtemp(path.join(tmpdir(), 'calver-bump-'));
127
312
  execFileSync('git', ['init'], { cwd: repo });