calver-bump 0.1.0
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 +50 -0
- package/bin/calver-bump.js +29 -0
- package/package.json +24 -0
- package/src/calver.js +41 -0
- package/src/index.js +169 -0
- package/test/calver.test.js +32 -0
- package/test/cli.test.js +44 -0
- package/test/release.test.js +155 -0
package/README.md
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# calver-bump
|
|
2
|
+
|
|
3
|
+
Release CLI for applications and internal tools that use readable CalVer versions.
|
|
4
|
+
|
|
5
|
+
Default version and tag format:
|
|
6
|
+
|
|
7
|
+
```text
|
|
8
|
+
YYYY.MM.DD.N
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Example:
|
|
12
|
+
|
|
13
|
+
```text
|
|
14
|
+
2026.05.29.1
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## What it does
|
|
18
|
+
|
|
19
|
+
1. Bumps `package.json` to the next CalVer version.
|
|
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.
|
|
22
|
+
4. Creates a release commit.
|
|
23
|
+
5. Creates a git tag.
|
|
24
|
+
|
|
25
|
+
## Usage
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
npx calver-bump
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Preview the planned release without writing files:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
npx calver-bump --dry-run
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Use compact CalVer instead:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
npx calver-bump --format compact
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Notes
|
|
44
|
+
|
|
45
|
+
- The default `dotted` format is `YYYY.MM.DD.N`.
|
|
46
|
+
- The optional `compact` format is `YYYYMMDD.N`.
|
|
47
|
+
- Existing `v`-prefixed tags are considered when calculating the next sequence number.
|
|
48
|
+
- Changelog ranges ignore non-CalVer tags.
|
|
49
|
+
- The working tree must be clean before creating a real release.
|
|
50
|
+
- If tag creation fails after the release commit, the CLI rolls back its own release commit.
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { runRelease } from '../src/index.js';
|
|
3
|
+
import { assertFormat } from '../src/calver.js';
|
|
4
|
+
|
|
5
|
+
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
|
+
|
|
10
|
+
try {
|
|
11
|
+
assertFormat(format);
|
|
12
|
+
const result = await runRelease({ dryRun, format });
|
|
13
|
+
console.log(`Release version: ${result.version}`);
|
|
14
|
+
for (const action of result.actions) {
|
|
15
|
+
console.log(`- ${action}`);
|
|
16
|
+
}
|
|
17
|
+
if (!dryRun) {
|
|
18
|
+
console.log('');
|
|
19
|
+
console.log('Next steps:');
|
|
20
|
+
console.log('1. Review the release commit:');
|
|
21
|
+
console.log(' git show --stat HEAD');
|
|
22
|
+
console.log('2. Push the release commit and tag:');
|
|
23
|
+
console.log(` git push --follow-tags origin ${result.branch}`);
|
|
24
|
+
console.log('3. Trigger or verify your deployment pipeline.');
|
|
25
|
+
}
|
|
26
|
+
} catch (error) {
|
|
27
|
+
console.error(error.message);
|
|
28
|
+
process.exitCode = 1;
|
|
29
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "calver-bump",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Release CLI for internal applications using readable CalVer versions.",
|
|
5
|
+
"main": "src/index.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"calver-bump": "bin/calver-bump.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"test": "node --test"
|
|
11
|
+
},
|
|
12
|
+
"keywords": [
|
|
13
|
+
"release",
|
|
14
|
+
"calver",
|
|
15
|
+
"changelog",
|
|
16
|
+
"git-tag"
|
|
17
|
+
],
|
|
18
|
+
"author": "",
|
|
19
|
+
"license": "MIT",
|
|
20
|
+
"type": "module",
|
|
21
|
+
"engines": {
|
|
22
|
+
"node": ">=20"
|
|
23
|
+
}
|
|
24
|
+
}
|
package/src/calver.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
export function nextCalVer({ date = new Date(), existingTags = [], format = 'dotted' } = {}) {
|
|
2
|
+
assertFormat(format);
|
|
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) => {
|
|
9
|
+
const match = matcher.exec(tag.trim());
|
|
10
|
+
if (!match) return max;
|
|
11
|
+
return Math.max(max, Number(match[1]));
|
|
12
|
+
}, 0);
|
|
13
|
+
|
|
14
|
+
return `${prefix}.${highest + 1}`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function assertFormat(format) {
|
|
18
|
+
if (!['dotted', 'compact'].includes(format)) {
|
|
19
|
+
throw new Error(`Invalid format "${format}". Expected "dotted" or "compact".`);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function isCalVerTag(tag) {
|
|
24
|
+
return /^v?\d{4}\.\d{2}\.\d{2}\.\d+$/.test(tag) || /^v?\d{8}\.\d+$/.test(tag);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function isoDate(date = new Date()) {
|
|
28
|
+
const parts = dateParts(date);
|
|
29
|
+
return `${parts.year}-${parts.month}-${parts.day}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function dateParts(date) {
|
|
33
|
+
const year = String(date.getFullYear());
|
|
34
|
+
const month = String(date.getMonth() + 1).padStart(2, '0');
|
|
35
|
+
const day = String(date.getDate()).padStart(2, '0');
|
|
36
|
+
return { year, month, day };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function escapeRegExp(value) {
|
|
40
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
41
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { execFile as execFileCallback } from 'node:child_process';
|
|
2
|
+
import { access, readFile, writeFile } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { promisify } from 'node:util';
|
|
5
|
+
|
|
6
|
+
import { isoDate, isCalVerTag, nextCalVer } from './calver.js';
|
|
7
|
+
|
|
8
|
+
const execFile = promisify(execFileCallback);
|
|
9
|
+
|
|
10
|
+
export async function planRelease(options = {}) {
|
|
11
|
+
const cwd = options.cwd ?? process.cwd();
|
|
12
|
+
const existingTags = options.existingTags ?? await gitLines(cwd, ['tag', '--list']);
|
|
13
|
+
const version = nextCalVer({
|
|
14
|
+
date: options.date,
|
|
15
|
+
existingTags,
|
|
16
|
+
format: options.format ?? 'dotted',
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
return {
|
|
20
|
+
version,
|
|
21
|
+
branch: await currentBranch(cwd),
|
|
22
|
+
actions: releaseActions(version),
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function runRelease(options = {}) {
|
|
27
|
+
const cwd = options.cwd ?? process.cwd();
|
|
28
|
+
const plan = await planRelease(options);
|
|
29
|
+
|
|
30
|
+
if (options.dryRun) {
|
|
31
|
+
return plan;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
await assertCleanWorktree(cwd);
|
|
35
|
+
await updatePackageVersion(cwd, plan.version);
|
|
36
|
+
await updatePackageLock(cwd, plan.version);
|
|
37
|
+
await prependChangelog(cwd, plan.version, options.date ?? new Date());
|
|
38
|
+
await git(cwd, ['add', ...await releaseFiles(cwd)]);
|
|
39
|
+
await git(cwd, ['commit', '-m', `chore(release): ${plan.version}`]);
|
|
40
|
+
try {
|
|
41
|
+
await git(cwd, ['tag', plan.version]);
|
|
42
|
+
} catch (error) {
|
|
43
|
+
await git(cwd, ['reset', '--hard', 'HEAD~1']);
|
|
44
|
+
throw new Error(`Failed to create git tag ${plan.version}; rolled back release commit. ${error.message}`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return plan;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function releaseActions(version) {
|
|
51
|
+
return [
|
|
52
|
+
`update package.json version to ${version}`,
|
|
53
|
+
`prepend CHANGELOG.md entry for ${version}`,
|
|
54
|
+
`create git commit chore(release): ${version}`,
|
|
55
|
+
`create git tag ${version}`,
|
|
56
|
+
];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function updatePackageVersion(cwd, version) {
|
|
60
|
+
const packagePath = path.join(cwd, 'package.json');
|
|
61
|
+
const pkg = JSON.parse(await readFile(packagePath, 'utf8'));
|
|
62
|
+
pkg.version = version;
|
|
63
|
+
await writeFile(packagePath, `${JSON.stringify(pkg, null, 2)}\n`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function updatePackageLock(cwd, version) {
|
|
67
|
+
for (const fileName of ['package-lock.json', 'npm-shrinkwrap.json']) {
|
|
68
|
+
const filePath = path.join(cwd, fileName);
|
|
69
|
+
let lock;
|
|
70
|
+
try {
|
|
71
|
+
lock = JSON.parse(await readFile(filePath, 'utf8'));
|
|
72
|
+
} catch (error) {
|
|
73
|
+
if (error.code === 'ENOENT') continue;
|
|
74
|
+
throw error;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (typeof lock.version === 'string') {
|
|
78
|
+
lock.version = version;
|
|
79
|
+
}
|
|
80
|
+
if (lock.packages?.[''] && typeof lock.packages[''].version === 'string') {
|
|
81
|
+
lock.packages[''].version = version;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
await writeFile(filePath, `${JSON.stringify(lock, null, 2)}\n`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function releaseFiles(cwd) {
|
|
89
|
+
const candidates = ['package.json', 'package-lock.json', 'npm-shrinkwrap.json', 'CHANGELOG.md'];
|
|
90
|
+
const files = [];
|
|
91
|
+
for (const candidate of candidates) {
|
|
92
|
+
if (await fileExists(path.join(cwd, candidate))) {
|
|
93
|
+
files.push(candidate);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return files;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function fileExists(filePath) {
|
|
100
|
+
try {
|
|
101
|
+
await access(filePath);
|
|
102
|
+
return true;
|
|
103
|
+
} catch {
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function prependChangelog(cwd, version, date) {
|
|
109
|
+
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`;
|
|
112
|
+
let existing = '';
|
|
113
|
+
|
|
114
|
+
try {
|
|
115
|
+
existing = await readFile(changelogPath, 'utf8');
|
|
116
|
+
} catch (error) {
|
|
117
|
+
if (error.code !== 'ENOENT') throw error;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const body = existing.trim().startsWith('# Changelog')
|
|
121
|
+
? existing.replace(/^# Changelog\s*/, `# Changelog\n\n${entry}\n`)
|
|
122
|
+
: `# Changelog\n\n${entry}\n${existing}`;
|
|
123
|
+
|
|
124
|
+
await writeFile(changelogPath, body);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function releaseNotes(cwd) {
|
|
128
|
+
const latestTag = await latestReachableTag(cwd);
|
|
129
|
+
const range = latestTag ? [`${latestTag}..HEAD`] : [];
|
|
130
|
+
const lines = await gitLines(cwd, ['log', '--pretty=%s', ...range]);
|
|
131
|
+
return lines.length > 0 ? lines : ['Initial internal release.'];
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function latestReachableTag(cwd) {
|
|
135
|
+
const tags = await gitLines(cwd, [
|
|
136
|
+
'for-each-ref',
|
|
137
|
+
'--merged',
|
|
138
|
+
'HEAD',
|
|
139
|
+
'--sort=-creatordate',
|
|
140
|
+
'--format=%(refname:short)',
|
|
141
|
+
'refs/tags',
|
|
142
|
+
]);
|
|
143
|
+
return tags.find(isCalVerTag) ?? null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function currentBranch(cwd) {
|
|
147
|
+
try {
|
|
148
|
+
const { stdout } = await git(cwd, ['branch', '--show-current']);
|
|
149
|
+
return stdout.trim() || 'HEAD';
|
|
150
|
+
} catch {
|
|
151
|
+
return 'HEAD';
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function assertCleanWorktree(cwd) {
|
|
156
|
+
const status = await gitLines(cwd, ['status', '--porcelain']);
|
|
157
|
+
if (status.length > 0) {
|
|
158
|
+
throw new Error('Working tree is not clean. Commit or stash changes before releasing.');
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function gitLines(cwd, args) {
|
|
163
|
+
const { stdout } = await git(cwd, args);
|
|
164
|
+
return stdout.split('\n').map((line) => line.trim()).filter(Boolean);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function git(cwd, args) {
|
|
168
|
+
return execFile('git', args, { cwd, encoding: 'utf8' });
|
|
169
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { test } from 'node:test';
|
|
3
|
+
|
|
4
|
+
import { nextCalVer } from '../src/calver.js';
|
|
5
|
+
|
|
6
|
+
test('nextCalVer defaults to readable dotted format YYYY.MM.DD.N', () => {
|
|
7
|
+
const version = nextCalVer({
|
|
8
|
+
date: new Date('2026-05-29T12:00:00-07:00'),
|
|
9
|
+
existingTags: [],
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
assert.equal(version, '2026.05.29.1');
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test('nextCalVer increments the sequence for existing tags on the same day', () => {
|
|
16
|
+
const version = nextCalVer({
|
|
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'],
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
assert.equal(version, '2026.05.29.3');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('nextCalVer can emit compact YYYYMMDD.N when requested', () => {
|
|
25
|
+
const version = nextCalVer({
|
|
26
|
+
date: new Date('2026-05-29T12:00:00-07:00'),
|
|
27
|
+
existingTags: ['20260529.1'],
|
|
28
|
+
format: 'compact',
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
assert.equal(version, '20260529.2');
|
|
32
|
+
});
|
package/test/cli.test.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { execFileSync, spawnSync } from 'node:child_process';
|
|
3
|
+
import { mkdtemp, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { test } from 'node:test';
|
|
7
|
+
|
|
8
|
+
test('CLI rejects invalid release formats', () => {
|
|
9
|
+
const result = spawnSync(process.execPath, ['bin/calver-bump.js', '--format', 'nope'], {
|
|
10
|
+
cwd: process.cwd(),
|
|
11
|
+
encoding: 'utf8',
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
assert.notEqual(result.status, 0);
|
|
15
|
+
assert.match(result.stderr, /Invalid format/);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test('CLI explains how to push the release commit and tag after a real release', async () => {
|
|
19
|
+
const repo = await makeRepo();
|
|
20
|
+
const cliPath = path.resolve('bin/calver-bump.js');
|
|
21
|
+
|
|
22
|
+
const result = spawnSync(process.execPath, [cliPath], {
|
|
23
|
+
cwd: repo,
|
|
24
|
+
encoding: 'utf8',
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
assert.equal(result.status, 0, result.stderr);
|
|
28
|
+
assert.match(result.stdout, /Next steps:/);
|
|
29
|
+
assert.match(result.stdout, /git push --follow-tags origin main/);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
async function makeRepo() {
|
|
33
|
+
const repo = await mkdtemp(path.join(tmpdir(), 'calver-bump-cli-'));
|
|
34
|
+
execFileSync('git', ['init'], { cwd: repo });
|
|
35
|
+
execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: repo });
|
|
36
|
+
execFileSync('git', ['config', 'user.name', 'Test User'], { cwd: repo });
|
|
37
|
+
await writeFile(
|
|
38
|
+
path.join(repo, 'package.json'),
|
|
39
|
+
`${JSON.stringify({ name: 'demo-app', version: '0.0.0' }, null, 2)}\n`,
|
|
40
|
+
);
|
|
41
|
+
execFileSync('git', ['add', 'package.json'], { cwd: repo });
|
|
42
|
+
execFileSync('git', ['commit', '-m', 'feat: initial app'], { cwd: repo });
|
|
43
|
+
return repo;
|
|
44
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { mkdtemp, readFile, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { test } from 'node:test';
|
|
6
|
+
import { execFileSync } from 'node:child_process';
|
|
7
|
+
|
|
8
|
+
import { planRelease, runRelease } from '../src/index.js';
|
|
9
|
+
|
|
10
|
+
test('planRelease reports version, changelog, commit, and tag actions without writing in dry-run mode', async () => {
|
|
11
|
+
const repo = await makeRepo();
|
|
12
|
+
|
|
13
|
+
const plan = await planRelease({
|
|
14
|
+
cwd: repo,
|
|
15
|
+
date: new Date('2026-05-29T12:00:00-07:00'),
|
|
16
|
+
dryRun: true,
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
assert.equal(plan.version, '2026.05.29.1');
|
|
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',
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
const pkg = JSON.parse(await readFile(path.join(repo, 'package.json'), 'utf8'));
|
|
28
|
+
assert.equal(pkg.version, '0.0.0');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('runRelease updates package.json, prepends changelog, commits, and tags', 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
|
+
});
|
|
38
|
+
|
|
39
|
+
assert.equal(result.version, '2026.05.29.1');
|
|
40
|
+
|
|
41
|
+
const pkg = JSON.parse(await readFile(path.join(repo, 'package.json'), 'utf8'));
|
|
42
|
+
assert.equal(pkg.version, '2026.05.29.1');
|
|
43
|
+
|
|
44
|
+
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/);
|
|
46
|
+
|
|
47
|
+
const tag = execFileSync('git', ['tag', '--list', '2026.05.29.1'], {
|
|
48
|
+
cwd: repo,
|
|
49
|
+
encoding: 'utf8',
|
|
50
|
+
}).trim();
|
|
51
|
+
assert.equal(tag, '2026.05.29.1');
|
|
52
|
+
|
|
53
|
+
const subject = execFileSync('git', ['log', '-1', '--pretty=%s'], {
|
|
54
|
+
cwd: repo,
|
|
55
|
+
encoding: 'utf8',
|
|
56
|
+
}).trim();
|
|
57
|
+
assert.equal(subject, 'chore(release): 2026.05.29.1');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('runRelease returns the current branch for push guidance', async () => {
|
|
61
|
+
const repo = await makeRepo();
|
|
62
|
+
execFileSync('git', ['checkout', '-b', 'release/train'], { cwd: repo });
|
|
63
|
+
|
|
64
|
+
const result = await runRelease({
|
|
65
|
+
cwd: repo,
|
|
66
|
+
date: new Date('2026-05-29T12:00:00-07:00'),
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
assert.equal(result.branch, 'release/train');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('runRelease updates package-lock.json when it exists', async () => {
|
|
73
|
+
const repo = await makeRepo({ packageLock: true });
|
|
74
|
+
|
|
75
|
+
await runRelease({
|
|
76
|
+
cwd: repo,
|
|
77
|
+
date: new Date('2026-05-29T12:00:00-07:00'),
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
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');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('runRelease uses the latest CalVer tag as the changelog base and ignores other tags', async () => {
|
|
86
|
+
const repo = await makeRepo();
|
|
87
|
+
execFileSync('git', ['tag', '2026.05.28.1'], { cwd: repo });
|
|
88
|
+
await writeFile(path.join(repo, 'feature-a.txt'), 'a\n');
|
|
89
|
+
execFileSync('git', ['add', 'feature-a.txt'], { cwd: repo });
|
|
90
|
+
execFileSync('git', ['commit', '-m', 'feat: after calver tag'], { cwd: repo });
|
|
91
|
+
execFileSync('git', ['tag', 'deploy-preview'], { cwd: repo });
|
|
92
|
+
await writeFile(path.join(repo, 'feature-b.txt'), 'b\n');
|
|
93
|
+
execFileSync('git', ['add', 'feature-b.txt'], { cwd: repo });
|
|
94
|
+
execFileSync('git', ['commit', '-m', 'fix: after non-release tag'], { cwd: repo });
|
|
95
|
+
|
|
96
|
+
await runRelease({
|
|
97
|
+
cwd: repo,
|
|
98
|
+
date: new Date('2026-05-29T12:00:00-07:00'),
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const changelog = await readFile(path.join(repo, 'CHANGELOG.md'), 'utf8');
|
|
102
|
+
assert.match(changelog, /- fix: after non-release tag/);
|
|
103
|
+
assert.match(changelog, /- feat: after calver tag/);
|
|
104
|
+
assert.doesNotMatch(changelog, /- feat: initial app/);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('runRelease rolls back its release commit when tag creation fails', async () => {
|
|
108
|
+
const repo = await makeRepo();
|
|
109
|
+
execFileSync('git', ['tag', '2026.05.29.1'], { cwd: repo });
|
|
110
|
+
const before = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: repo, encoding: 'utf8' }).trim();
|
|
111
|
+
|
|
112
|
+
await assert.rejects(
|
|
113
|
+
() => runRelease({
|
|
114
|
+
cwd: repo,
|
|
115
|
+
date: new Date('2026-05-29T12:00:00-07:00'),
|
|
116
|
+
existingTags: [],
|
|
117
|
+
}),
|
|
118
|
+
/Failed to create git tag/,
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
const after = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: repo, encoding: 'utf8' }).trim();
|
|
122
|
+
assert.equal(after, before);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
async function makeRepo({ packageLock = false } = {}) {
|
|
126
|
+
const repo = await mkdtemp(path.join(tmpdir(), 'calver-bump-'));
|
|
127
|
+
execFileSync('git', ['init'], { cwd: repo });
|
|
128
|
+
execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: repo });
|
|
129
|
+
execFileSync('git', ['config', 'user.name', 'Test User'], { cwd: repo });
|
|
130
|
+
execFileSync('git', ['checkout', '-b', 'main'], { cwd: repo });
|
|
131
|
+
await writeFile(
|
|
132
|
+
path.join(repo, 'package.json'),
|
|
133
|
+
`${JSON.stringify({ name: 'demo-app', version: '0.0.0' }, null, 2)}\n`,
|
|
134
|
+
);
|
|
135
|
+
if (packageLock) {
|
|
136
|
+
await writeFile(
|
|
137
|
+
path.join(repo, 'package-lock.json'),
|
|
138
|
+
`${JSON.stringify({
|
|
139
|
+
name: 'demo-app',
|
|
140
|
+
version: '0.0.0',
|
|
141
|
+
lockfileVersion: 3,
|
|
142
|
+
requires: true,
|
|
143
|
+
packages: {
|
|
144
|
+
'': {
|
|
145
|
+
name: 'demo-app',
|
|
146
|
+
version: '0.0.0',
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
}, null, 2)}\n`,
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
execFileSync('git', ['add', '.'], { cwd: repo });
|
|
153
|
+
execFileSync('git', ['commit', '-m', 'feat: initial app'], { cwd: repo });
|
|
154
|
+
return repo;
|
|
155
|
+
}
|