calver-bump 0.1.8 → 1.0.1

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
@@ -2,16 +2,23 @@
2
2
 
3
3
  Release CLI for applications and internal tools that use readable CalVer versions.
4
4
 
5
- Default version and tag format:
5
+ Default version format:
6
6
 
7
7
  ```text
8
8
  YY.MMDD
9
9
  ```
10
10
 
11
- Example:
11
+ Default git tag format:
12
+
13
+ ```text
14
+ vYY.MMDD
15
+ ```
16
+
17
+ Example package version and git tag:
12
18
 
13
19
  ```text
14
20
  26.0529
21
+ v26.0529
15
22
  ```
16
23
 
17
24
  ## What it does
@@ -29,6 +36,8 @@ Example:
29
36
  npx calver-bump
30
37
  ```
31
38
 
39
+ By default, `calver-bump` updates release files, creates a release commit, and creates an annotated `v`-prefixed git tag.
40
+
32
41
  Preview the planned release without writing files:
33
42
 
34
43
  ```bash
@@ -59,10 +68,10 @@ Use long CalVer instead:
59
68
  npx calver-bump --format long
60
69
  ```
61
70
 
62
- Create a `v`-prefixed git tag while keeping `package.json` unprefixed:
71
+ Create an unprefixed git tag while keeping the default package version unchanged:
63
72
 
64
73
  ```bash
65
- npx calver-bump --tag-prefix v
74
+ npx calver-bump --tag-prefix ""
66
75
  ```
67
76
 
68
77
  Push the release commit and annotated tag:
@@ -107,7 +116,19 @@ Update only `CHANGELOG.md`:
107
116
  npx calver-bump --changelog-only
108
117
  ```
109
118
 
110
- Update files without creating a release commit or tag:
119
+ Create a release commit after updating files:
120
+
121
+ ```bash
122
+ npx calver-bump --commit
123
+ ```
124
+
125
+ Create a release commit and annotated tag after updating files:
126
+
127
+ ```bash
128
+ npx calver-bump --tag
129
+ ```
130
+
131
+ Explicitly update files without creating a release commit or tag:
111
132
 
112
133
  ```bash
113
134
  npx calver-bump --skip-commit
@@ -152,7 +173,12 @@ The package includes a manual GitHub Actions publish workflow. Run the `Publish`
152
173
  - Changelog entries fall back to commit hash links for GitHub and GitLab-style remotes when no pull/merge request reference is found.
153
174
  - Release entries include a `Full Changelog` section with a deduped list of pull/merge requests found in the release range, including the local commit title when available.
154
175
  - Later releases prepend only commits since the previous nearest reachable tag.
176
+ - Plain `calver-bump` creates the release commit and annotated tag by default.
177
+ - Use `--commit` to create only the release commit.
178
+ - Use `--tag` to create the release commit and annotated tag.
179
+ - Use `--skip-commit` to update files only without creating a release commit or tag.
180
+ - Use `--push` to create the release commit, create the annotated tag, and push both.
155
181
  - Release tags are annotated so `git push --follow-tags <remote> <branch>` pushes them.
156
- - The working tree must be clean before creating a real release.
182
+ - Unrelated dirty working-tree files do not block a release; only release files are staged.
157
183
  - Existing release tags are rejected before files are written.
158
184
  - If tag creation fails after the release commit, the CLI undoes its own commit and leaves the file changes in the working tree for inspection or recovery.
@@ -10,6 +10,7 @@ const execFile = promisify(execFileCallback);
10
10
  const args = process.argv.slice(2);
11
11
  const KNOWN_OPTIONS = new Set([
12
12
  '--changelog-only',
13
+ '--commit',
13
14
  '--dry-run',
14
15
  '--format',
15
16
  '--from',
@@ -18,6 +19,7 @@ const KNOWN_OPTIONS = new Set([
18
19
  '--push',
19
20
  '--remote',
20
21
  '--skip-commit',
22
+ '--tag',
21
23
  '--tag-prefix',
22
24
  '--types',
23
25
  '--version',
@@ -49,19 +51,17 @@ try {
49
51
  for (const action of result.actions) {
50
52
  console.log(`- ${action}`);
51
53
  }
52
- if (options.push && !options.dryRun && createsGitObjects(options)) {
54
+ if (options.push && !options.dryRun && createsTag(options)) {
53
55
  const pushArgs = ['push', '--follow-tags', result.remote, result.branch];
54
56
  console.log('');
55
57
  console.log(`Running: git ${pushArgs.join(' ')}`);
56
58
  await execFile('git', pushArgs, { encoding: 'utf8' });
57
- } else if (!options.dryRun && createsGitObjects(options)) {
59
+ } else if (!options.dryRun) {
58
60
  console.log('');
59
61
  console.log('Next steps:');
60
- console.log('1. Review the release commit:');
61
- console.log(' git show --stat HEAD');
62
- console.log('2. Push the release commit and tag:');
63
- console.log(` git push --follow-tags ${result.remote} ${result.branch}`);
64
- console.log('3. Trigger or verify your deployment pipeline.');
62
+ for (const instruction of nextStepInstructions(result, options)) {
63
+ console.log(instruction);
64
+ }
65
65
  }
66
66
  } catch (error) {
67
67
  console.error(error.message);
@@ -73,21 +73,33 @@ async function releaseOptions(args) {
73
73
  const config = await readConfig();
74
74
  const versionOnly = flag(args, '--version-only') ?? config.versionOnly ?? false;
75
75
  const changelogOnly = flag(args, '--changelog-only') ?? config.changelogOnly ?? false;
76
+ const skipCommit = flag(args, '--skip-commit') ?? config.skipCommit ?? false;
77
+ const push = flag(args, '--push') ?? config.push ?? false;
78
+ const commitFlag = flag(args, '--commit');
79
+ const tagFlag = flag(args, '--tag');
80
+ const writesOnly = skipCommit || versionOnly || changelogOnly;
81
+ const tag = !writesOnly && (push || (tagFlag ?? config.tag ?? (commitFlag || config.commit ? false : true)));
82
+ const commit = tag || (!writesOnly && (commitFlag ?? config.commit ?? false));
76
83
  if (versionOnly && changelogOnly) {
77
84
  throw new Error('--version-only cannot be combined with --changelog-only.');
78
85
  }
86
+ if (skipCommit && (commitFlag || tagFlag || config.commit || config.tag || push)) {
87
+ throw new Error('--skip-commit cannot be combined with --commit, --tag, or --push.');
88
+ }
79
89
  return {
80
90
  ...config,
81
91
  dryRun: flag(args, '--dry-run') ?? config.dryRun ?? false,
82
92
  noFetch: flag(args, '--no-fetch') ?? config.noFetch ?? false,
83
- push: flag(args, '--push') ?? config.push ?? false,
84
- skipCommit: flag(args, '--skip-commit') ?? config.skipCommit ?? false,
93
+ commit,
94
+ tag,
95
+ push,
96
+ skipCommit,
85
97
  versionOnly,
86
98
  changelogOnly,
87
99
  from: value(args, '--from') ?? config.from,
88
100
  format: value(args, '--format') ?? config.format ?? 'short',
89
101
  remote: value(args, '--remote') ?? config.remote ?? 'origin',
90
- tagPrefix: value(args, '--tag-prefix') ?? config.tagPrefix ?? '',
102
+ tagPrefix: value(args, '--tag-prefix') ?? config.tagPrefix ?? 'v',
91
103
  types: parseTypes(value(args, '--types')) ?? config.types,
92
104
  changelogSections: config.changelogSections,
93
105
  };
@@ -134,7 +146,7 @@ function rejectUnknownOptions(args) {
134
146
 
135
147
  function printResult(result, options) {
136
148
  console.log(`Release version: ${result.version}`);
137
- console.log(`Git tag: ${createsGitObjects(options) ? result.tag : '(not created)'}`);
149
+ console.log(`Git tag: ${result.createdTag ?? '(not created)'}`);
138
150
  console.log(`Branch: ${result.branch}`);
139
151
  console.log(`Remote: ${result.remote}`);
140
152
  if (!options.versionOnly) {
@@ -151,8 +163,42 @@ function printResult(result, options) {
151
163
  console.log(options.dryRun ? 'Planned actions:' : 'Completed actions:');
152
164
  }
153
165
 
154
- function createsGitObjects(options) {
155
- return !options.skipCommit && !options.versionOnly && !options.changelogOnly;
166
+ function createsCommit(options) {
167
+ return Boolean(options.commit || options.tag || options.push) && !options.skipCommit && !options.versionOnly && !options.changelogOnly;
168
+ }
169
+
170
+ function createsTag(options) {
171
+ return Boolean(options.tag || options.push) && createsCommit(options);
172
+ }
173
+
174
+ function nextStepInstructions(result, options) {
175
+ if (options.versionOnly || options.changelogOnly) {
176
+ return ['Review the generated file changes.'];
177
+ }
178
+ if (!createsCommit(options)) {
179
+ return [
180
+ 'Review CHANGELOG.md, then run:',
181
+ `git add ${result.files.join(' ')}`,
182
+ `git commit -m "chore(release): ${result.version}"`,
183
+ `git tag -a ${result.tag} -m "Release ${result.version}"`,
184
+ `git push --follow-tags ${result.remote} ${result.branch}`,
185
+ ];
186
+ }
187
+ if (!createsTag(options)) {
188
+ return [
189
+ 'Review the release commit, then run:',
190
+ 'git show --stat HEAD',
191
+ `git tag -a ${result.tag} -m "Release ${result.version}"`,
192
+ `git push --follow-tags ${result.remote} ${result.branch}`,
193
+ ];
194
+ }
195
+ return [
196
+ 'Review the release commit:',
197
+ 'git show --stat HEAD',
198
+ 'Push the release commit and tag:',
199
+ `git push --follow-tags ${result.remote} ${result.branch}`,
200
+ 'Trigger or verify your deployment pipeline.',
201
+ ];
156
202
  }
157
203
 
158
204
  function helpText() {
@@ -161,14 +207,16 @@ function helpText() {
161
207
  Options:
162
208
  --dry-run Preview the release without writing files.
163
209
  --format <name> Version format: short, compact, or long.
164
- --tag-prefix <prefix> Prefix the git tag without changing package.json.
210
+ --tag-prefix <prefix> Prefix the git tag without changing package.json (default: v).
165
211
  --types <list> Comma-separated conventional commit types to include.
166
212
  --from <tag> Use an explicit changelog base tag.
167
213
  --no-fetch Use local tags only; do not fetch remote tags.
168
214
  --version-only Update package.json and supported npm lockfiles only.
169
215
  --changelog-only Update CHANGELOG.md only.
170
- --skip-commit Update files without creating a release commit or tag.
171
- --push Push the release commit and annotated tag.
216
+ --commit Create the release commit after updating files.
217
+ --tag Create the release commit and annotated tag.
218
+ --skip-commit Explicitly update files without creating a release commit or tag.
219
+ --push Create, tag, and push the release.
172
220
  --remote <name> Remote used for fetch, links, and push.
173
221
  --version Print the calver-bump package version.
174
222
  --help Show this help.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "calver-bump",
3
- "version": "0.1.8",
3
+ "version": "1.0.1",
4
4
  "description": "Release CLI for internal applications using readable CalVer versions.",
5
5
  "repository": {
6
6
  "type": "git",
package/src/changelog.js CHANGED
@@ -21,6 +21,7 @@ export async function releaseNotes(cwd, options = {}) {
21
21
  const conventionalCommits = dedupeConventionalChanges(commits
22
22
  .map((commit) => ({ ...commit, subject: conventionalSubjectForCommit(commit) ?? commit.subject }))
23
23
  .filter((commit) => isConventionalCommit(commit.subject))
24
+ .filter((commit) => !isReleaseCommit(commit.subject))
24
25
  .filter((commit) => allowedTypes.includes(conventionalType(commit.subject)))
25
26
  .filter((commit) => !isCommitInChangelog(commit, options.existingChangelog ?? ''))
26
27
  .map((commit) => ({
@@ -28,7 +29,10 @@ export async function releaseNotes(cwd, options = {}) {
28
29
  request: requestForCommit(commit, requestUrlBuilder),
29
30
  url: commitUrlBuilder ? commitUrlBuilder(commit.hash) : null,
30
31
  })));
31
- const requests = uniqueRequests(commits.map((commit) => requestForCommit(commit, requestUrlBuilder)).filter(Boolean));
32
+ const requests = uniqueRequests(commits
33
+ .filter((commit) => !isReleaseCommit(conventionalSubjectForCommit(commit) ?? commit.subject))
34
+ .map((commit) => requestForCommit(commit, requestUrlBuilder))
35
+ .filter(Boolean));
32
36
  return {
33
37
  previousTag: latestTag,
34
38
  range: latestTag ? `${latestTag}..HEAD` : 'HEAD',
@@ -39,10 +43,7 @@ export async function releaseNotes(cwd, options = {}) {
39
43
  }
40
44
 
41
45
  export function formatReleaseHeading({ version, previousTag, tag, compareUrlBuilder }) {
42
- const label = previousTag && compareUrlBuilder
43
- ? `[${version}](${compareUrlBuilder(previousTag, tag)})`
44
- : version;
45
- return `## ${label}`;
46
+ return `## ${version}`;
46
47
  }
47
48
 
48
49
  export function formatReleaseNotes(changes, sectionConfig = {}) {
@@ -72,11 +73,15 @@ export function formatReleaseNotes(changes, sectionConfig = {}) {
72
73
  .join('\n\n');
73
74
  }
74
75
 
75
- export function formatFullChangelog(requests) {
76
- if (requests.length === 0) {
76
+ export function formatFullChangelog(requests, compareUrl = null) {
77
+ const entries = [
78
+ ...requests.map((request) => formatRequestEntry(request)),
79
+ ...(compareUrl ? [`[Compare changes](${compareUrl})`] : []),
80
+ ];
81
+ if (entries.length === 0) {
77
82
  return '';
78
83
  }
79
- return `\n\n### Full Changelog\n\n${requests.map((request) => `- ${formatRequestEntry(request)}`).join('\n')}`;
84
+ return `\n\n### Full Changelog\n\n${entries.map((entry) => `- ${entry}`).join('\n')}`;
80
85
  }
81
86
 
82
87
  async function latestReleaseTag(cwd, changelog) {
@@ -88,7 +93,7 @@ async function latestReleaseTag(cwd, changelog) {
88
93
  }
89
94
 
90
95
  function latestChangelogCompareTarget(changelog) {
91
- const match = /^## \[[^\]]+\]\([^)]*\/(?:-\/)?compare\/[^)]*?\.{3}(?<tag>[^)\s]+)\)/m.exec(changelog);
96
+ const match = /\[[^\]]+\]\([^)]*\/(?:-\/)?compare\/[^)]*?\.{3}(?<tag>[^)\s]+)\)/m.exec(changelog);
92
97
  return match?.groups.tag ?? null;
93
98
  }
94
99
 
@@ -101,6 +106,10 @@ function isConventionalCommit(subject) {
101
106
  return /^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\([^)]+\))?!?: .+/.test(subject);
102
107
  }
103
108
 
109
+ function isReleaseCommit(subject) {
110
+ return /^chore\(release\):\s.+/.test(subject);
111
+ }
112
+
104
113
  function conventionalSubjectForCommit(commit) {
105
114
  return commitLines(commit).find((line) => isConventionalCommit(line)) ?? null;
106
115
  }
@@ -132,14 +141,16 @@ function conventionalType(subject) {
132
141
 
133
142
  function formatCommitEntry(commit) {
134
143
  const shortHash = commit.hash.slice(0, 7);
135
- const suffix = commit.request
136
- ? ` (${formatRequestLink(commit.request)})`
137
- : commit.url ? ` ([${shortHash}](${commit.url}))` : ` (${shortHash})`;
144
+ const links = [
145
+ shortHash,
146
+ commit.request ? formatRequestLink(commit.request) : null,
147
+ ].filter(Boolean);
148
+ const suffix = ` (${links.join(', ')})`;
138
149
  return `${commit.subject}${suffix}`;
139
150
  }
140
151
 
141
152
  function formatRequestLink(request) {
142
- return request.url ? `[${request.label}](${request.url})` : request.label;
153
+ return request.label;
143
154
  }
144
155
 
145
156
  function formatRequestEntry(request) {
@@ -220,7 +231,7 @@ function requestTitleForCommit(commit) {
220
231
  }
221
232
 
222
233
  function parseRequestReference(message) {
223
- const gitlabMerge = /(?:^|\s)(?:See merge request\s+\S+!|!)(?<number>\d+)(?=\D|$)/i.exec(message);
234
+ const gitlabMerge = /(?:^|[\s(])(?:See merge request\s+\S+!|!)(?<number>\d+)(?=\D|$)/i.exec(message);
224
235
  if (gitlabMerge) {
225
236
  return { provider: 'gitlab', number: gitlabMerge.groups.number, label: `!${gitlabMerge.groups.number}` };
226
237
  }
@@ -257,7 +268,7 @@ function parseGitRemote(remote) {
257
268
  return sshMatch.groups;
258
269
  }
259
270
 
260
- const httpsMatch = /^https:\/\/(?<host>[^/]+)\/(?<repo>.+?)(?:\.git)?$/.exec(remote);
271
+ const httpsMatch = /^https?:\/\/(?<host>[^/]+)\/(?<repo>.+?)(?:\.git)?$/.exec(remote);
261
272
  if (httpsMatch) {
262
273
  return httpsMatch.groups;
263
274
  }
package/src/files.js CHANGED
@@ -32,18 +32,18 @@ export async function updatePackageLock(cwd, version) {
32
32
 
33
33
  export async function releaseFiles(cwd, options = {}) {
34
34
  const candidates = [];
35
+ const files = [];
35
36
  if (options.version !== false) {
36
37
  candidates.push('package.json', 'package-lock.json', 'npm-shrinkwrap.json');
37
38
  }
38
- if (options.changelog !== false) {
39
- candidates.push('CHANGELOG.md');
40
- }
41
- const files = [];
42
39
  for (const candidate of candidates) {
43
40
  if (await fileExists(path.join(cwd, candidate))) {
44
41
  files.push(candidate);
45
42
  }
46
43
  }
44
+ if (options.changelog !== false) {
45
+ files.push('CHANGELOG.md');
46
+ }
47
47
  return files;
48
48
  }
49
49
 
package/src/index.js CHANGED
@@ -8,7 +8,7 @@ import {
8
8
  updatePackageVersion,
9
9
  writeChangelog,
10
10
  } from './files.js';
11
- import { assertCleanWorktree, assertTagAvailable, currentBranch, git, gitLines } from './git.js';
11
+ import { assertTagAvailable, currentBranch, git, gitLines } from './git.js';
12
12
 
13
13
  export async function planRelease(options = {}) {
14
14
  const cwd = options.cwd ?? process.cwd();
@@ -18,7 +18,7 @@ export async function planRelease(options = {}) {
18
18
  existingTags,
19
19
  format: options.format ?? 'short',
20
20
  });
21
- const tag = `${options.tagPrefix ?? ''}${version}`;
21
+ const tag = `${options.tagPrefix ?? 'v'}${version}`;
22
22
  const notes = options.versionOnly
23
23
  ? null
24
24
  : await releaseNotes(cwd, { ...options, tag, existingChangelog: await readChangelog(cwd) });
@@ -42,7 +42,8 @@ export async function planRelease(options = {}) {
42
42
  actions: releaseActions({
43
43
  version,
44
44
  tag,
45
- skipCommit: options.skipCommit || options.versionOnly || options.changelogOnly,
45
+ commit: createsCommit(options),
46
+ tagRelease: createsTag(options),
46
47
  versionOnly: options.versionOnly,
47
48
  changelogOnly: options.changelogOnly,
48
49
  files,
@@ -58,8 +59,7 @@ export async function runRelease(options = {}) {
58
59
  return plan;
59
60
  }
60
61
 
61
- await assertCleanWorktree(cwd);
62
- if (!options.skipCommit && !options.versionOnly && !options.changelogOnly) {
62
+ if (createsTag(options)) {
63
63
  await assertTagAvailable(cwd, plan.tag);
64
64
  }
65
65
 
@@ -71,23 +71,25 @@ export async function runRelease(options = {}) {
71
71
  await prependChangelog(cwd, plan.version, options);
72
72
  }
73
73
 
74
- if (options.skipCommit || options.versionOnly || options.changelogOnly) {
75
- return { ...plan, tag: null };
74
+ if (!createsCommit(options)) {
75
+ return { ...plan, createdTag: null };
76
76
  }
77
77
 
78
78
  await git(cwd, ['add', ...await releaseFiles(cwd)]);
79
79
  await git(cwd, ['commit', '-m', `chore(release): ${plan.version}`]);
80
- try {
81
- await git(cwd, ['tag', '-a', plan.tag, '-m', `Release ${plan.version}`]);
82
- } catch (error) {
83
- await git(cwd, ['reset', '--soft', 'HEAD~1']);
84
- throw new Error(`Failed to create git tag ${plan.tag}; release commit was undone and file changes were left in the working tree. ${error.message}`);
80
+ if (createsTag(options)) {
81
+ try {
82
+ await git(cwd, ['tag', '-a', plan.tag, '-m', `Release ${plan.version}`]);
83
+ } catch (error) {
84
+ await git(cwd, ['reset', '--soft', 'HEAD~1']);
85
+ throw new Error(`Failed to create git tag ${plan.tag}; release commit was undone and file changes were left in the working tree. ${error.message}`);
86
+ }
85
87
  }
86
88
 
87
- return plan;
89
+ return createsTag(options) ? { ...plan, createdTag: plan.tag } : { ...plan, createdTag: null };
88
90
  }
89
91
 
90
- function releaseActions({ version, tag, skipCommit = false, versionOnly = false, changelogOnly = false, files = [] }) {
92
+ function releaseActions({ version, tag, commit = false, tagRelease = false, versionOnly = false, changelogOnly = false, files = [] }) {
91
93
  const actions = [];
92
94
  if (!changelogOnly) {
93
95
  actions.push(`update package.json version to ${version}`);
@@ -98,28 +100,41 @@ function releaseActions({ version, tag, skipCommit = false, versionOnly = false,
98
100
  if (!versionOnly) {
99
101
  actions.push(`prepend CHANGELOG.md entry for ${version}`);
100
102
  }
101
- if (!skipCommit) {
103
+ if (commit) {
102
104
  actions.push(`create git commit chore(release): ${version}`);
105
+ }
106
+ if (tagRelease) {
103
107
  actions.push(`create git tag ${tag}`);
104
108
  }
105
109
  return actions;
106
110
  }
107
111
 
112
+ function createsCommit(options) {
113
+ return Boolean(options.commit || options.tag || options.push) && !options.skipCommit && !options.versionOnly && !options.changelogOnly;
114
+ }
115
+
116
+ function createsTag(options) {
117
+ return Boolean(options.tag || options.push) && createsCommit(options);
118
+ }
119
+
108
120
  async function prependChangelog(cwd, version, options = {}) {
109
121
  const existing = await readChangelog(cwd);
110
122
 
111
123
  const notes = await releaseNotes(cwd, {
112
124
  ...options,
113
125
  existingChangelog: existing,
114
- tag: `${options.tagPrefix ?? ''}${version}`,
126
+ tag: `${options.tagPrefix ?? 'v'}${version}`,
115
127
  });
116
128
  const heading = formatReleaseHeading({
117
129
  version,
118
130
  previousTag: notes.previousTag,
119
- tag: options.tagPrefix ? `${options.tagPrefix}${version}` : version,
131
+ tag: `${options.tagPrefix ?? 'v'}${version}`,
120
132
  compareUrlBuilder: notes.compareUrlBuilder,
121
133
  });
122
- const entry = `${heading}\n\n${formatReleaseNotes(notes.changes, options.changelogSections)}${formatFullChangelog(notes.requests)}\n`;
134
+ const compareUrl = notes.previousTag && notes.compareUrlBuilder
135
+ ? notes.compareUrlBuilder(notes.previousTag, `${options.tagPrefix ?? 'v'}${version}`)
136
+ : null;
137
+ const entry = `${heading}\n\n${formatReleaseNotes(notes.changes, options.changelogSections)}${formatFullChangelog(notes.requests, compareUrl)}\n`;
123
138
 
124
139
  const body = existing.trim().startsWith('# Changelog')
125
140
  ? existing.replace(/^# Changelog\s*/, `# Changelog\n\n${entry}\n`)