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 +62 -7
- package/bin/calver-bump.js +60 -7
- package/package.json +1 -1
- package/src/calver.js +32 -14
- package/src/index.js +122 -17
- package/test/calver.test.js +44 -7
- package/test/cli.test.js +65 -0
- package/test/release.test.js +201 -16
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
|
-
|
|
8
|
+
YY.MMDD
|
|
9
9
|
```
|
|
10
10
|
|
|
11
11
|
Example:
|
|
12
12
|
|
|
13
13
|
```text
|
|
14
|
-
|
|
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
|
|
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
|
|
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 `
|
|
46
|
-
- The optional `compact` format is `
|
|
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
|
|
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.
|
package/bin/calver-bump.js
CHANGED
|
@@ -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
|
-
|
|
12
|
-
|
|
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
|
|
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
package/src/calver.js
CHANGED
|
@@ -1,27 +1,34 @@
|
|
|
1
|
-
export function nextCalVer({ date = new Date(), existingTags = [], format = '
|
|
1
|
+
export function nextCalVer({ date = new Date(), existingTags = [], format = 'short' } = {}) {
|
|
2
2
|
assertFormat(format);
|
|
3
3
|
const parts = dateParts(date);
|
|
4
|
-
const prefix = format
|
|
5
|
-
|
|
6
|
-
|
|
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
|
|
11
|
-
return
|
|
12
|
-
|
|
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
|
-
|
|
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 (!['
|
|
19
|
-
throw new Error(`Invalid format "${format}". Expected "
|
|
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{
|
|
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,
|
|
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 ?? '
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
|
131
|
-
|
|
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
|
|
248
|
+
return tags[0] ?? null;
|
|
144
249
|
}
|
|
145
250
|
|
|
146
251
|
async function currentBranch(cwd) {
|
package/test/calver.test.js
CHANGED
|
@@ -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
|
|
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, '
|
|
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: ['
|
|
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, '
|
|
39
|
+
assert.equal(version, '260529');
|
|
22
40
|
});
|
|
23
41
|
|
|
24
|
-
test('nextCalVer can emit compact
|
|
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: ['
|
|
45
|
+
existingTags: ['260529'],
|
|
28
46
|
format: 'compact',
|
|
29
47
|
});
|
|
30
48
|
|
|
31
|
-
assert.equal(version, '
|
|
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
|
+
}
|
package/test/release.test.js
CHANGED
|
@@ -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, '
|
|
19
|
+
assert.equal(plan.version, '26.0529');
|
|
20
20
|
assert.deepEqual(plan.actions, [
|
|
21
|
-
'update package.json version to
|
|
22
|
-
'prepend CHANGELOG.md entry for
|
|
23
|
-
'create git commit chore(release):
|
|
24
|
-
'create git tag
|
|
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, '
|
|
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, '
|
|
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##
|
|
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', '
|
|
69
|
+
const tag = execFileSync('git', ['tag', '--list', '26.0529'], {
|
|
48
70
|
cwd: repo,
|
|
49
71
|
encoding: 'utf8',
|
|
50
72
|
}).trim();
|
|
51
|
-
assert.equal(tag, '
|
|
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):
|
|
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, '
|
|
82
|
-
assert.equal(lock.packages[''].version, '
|
|
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
|
|
107
|
+
test('runRelease uses the latest reachable tag as the changelog base', async () => {
|
|
86
108
|
const repo = await makeRepo();
|
|
87
|
-
execFileSync('git', ['tag', '
|
|
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', '
|
|
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 });
|