calver-bump 0.1.7 → 0.1.8
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 +56 -6
- package/bin/calver-bump.js +99 -3
- package/package.json +15 -2
- package/src/changelog.js +266 -0
- package/src/files.js +83 -0
- package/src/git.js +82 -0
- package/src/index.js +67 -402
- package/test/calver.test.js +0 -69
- package/test/cli.test.js +0 -109
- package/test/release.test.js +0 -568
package/src/git.js
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { execFile as execFileCallback } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
|
|
4
|
+
const execFile = promisify(execFileCallback);
|
|
5
|
+
|
|
6
|
+
export async function git(cwd, args) {
|
|
7
|
+
return execFile('git', args, { cwd, encoding: 'utf8' });
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export async function gitLines(cwd, args) {
|
|
11
|
+
const { stdout } = await git(cwd, args);
|
|
12
|
+
return stdout.split('\n').map((line) => line.trim()).filter(Boolean);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function gitCommits(cwd, range) {
|
|
16
|
+
const { stdout } = await git(cwd, ['log', '--pretty=format:%H%x00%s%x00%B%x1e', ...range]);
|
|
17
|
+
return stdout
|
|
18
|
+
.split('\x1e')
|
|
19
|
+
.filter(Boolean)
|
|
20
|
+
.map((record) => {
|
|
21
|
+
const [hash, subject, body = ''] = record.replace(/^\n+|\n+$/g, '').split('\0');
|
|
22
|
+
return { hash, subject, body };
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function getRemoteUrl(cwd, remote) {
|
|
27
|
+
try {
|
|
28
|
+
const { stdout } = await git(cwd, ['remote', 'get-url', remote]);
|
|
29
|
+
return stdout.trim();
|
|
30
|
+
} catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function fetchTags(cwd, remote) {
|
|
36
|
+
try {
|
|
37
|
+
await git(cwd, ['remote', 'get-url', remote]);
|
|
38
|
+
await git(cwd, ['fetch', '--tags', remote]);
|
|
39
|
+
} catch {
|
|
40
|
+
// Local/offline repos can still release using tags already present.
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function currentBranch(cwd) {
|
|
45
|
+
try {
|
|
46
|
+
const { stdout } = await git(cwd, ['branch', '--show-current']);
|
|
47
|
+
return stdout.trim() || 'HEAD';
|
|
48
|
+
} catch {
|
|
49
|
+
return 'HEAD';
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function assertCleanWorktree(cwd) {
|
|
54
|
+
const status = await gitLines(cwd, ['status', '--porcelain']);
|
|
55
|
+
if (status.length > 0) {
|
|
56
|
+
throw new Error('Working tree is not clean. Commit or stash changes before releasing.');
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function tagExists(cwd, tag) {
|
|
61
|
+
try {
|
|
62
|
+
await git(cwd, ['rev-parse', '--verify', '--quiet', `refs/tags/${tag}^{}`]);
|
|
63
|
+
return true;
|
|
64
|
+
} catch {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function assertTagAvailable(cwd, tag) {
|
|
70
|
+
if (await tagExists(cwd, tag)) {
|
|
71
|
+
throw new Error(`Git tag ${tag} already exists. Choose another date/format or delete the existing tag before releasing.`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function latestReachableTag(cwd) {
|
|
76
|
+
try {
|
|
77
|
+
const { stdout } = await git(cwd, ['describe', '--tags', '--abbrev=0']);
|
|
78
|
+
return stdout.trim() || null;
|
|
79
|
+
} catch {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
}
|
package/src/index.js
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
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
1
|
import { nextCalVer } from './calver.js';
|
|
7
|
-
|
|
8
|
-
|
|
2
|
+
import { formatFullChangelog, formatReleaseHeading, formatReleaseNotes, releaseNotes } from './changelog.js';
|
|
3
|
+
import {
|
|
4
|
+
readChangelog,
|
|
5
|
+
releaseFiles,
|
|
6
|
+
releaseWarnings,
|
|
7
|
+
updatePackageLock,
|
|
8
|
+
updatePackageVersion,
|
|
9
|
+
writeChangelog,
|
|
10
|
+
} from './files.js';
|
|
11
|
+
import { assertCleanWorktree, assertTagAvailable, currentBranch, git, gitLines } from './git.js';
|
|
9
12
|
|
|
10
13
|
export async function planRelease(options = {}) {
|
|
11
14
|
const cwd = options.cwd ?? process.cwd();
|
|
@@ -16,13 +19,34 @@ export async function planRelease(options = {}) {
|
|
|
16
19
|
format: options.format ?? 'short',
|
|
17
20
|
});
|
|
18
21
|
const tag = `${options.tagPrefix ?? ''}${version}`;
|
|
22
|
+
const notes = options.versionOnly
|
|
23
|
+
? null
|
|
24
|
+
: await releaseNotes(cwd, { ...options, tag, existingChangelog: await readChangelog(cwd) });
|
|
25
|
+
const files = await releaseFiles(cwd, {
|
|
26
|
+
version: !options.changelogOnly,
|
|
27
|
+
changelog: !options.versionOnly,
|
|
28
|
+
});
|
|
29
|
+
const warnings = await releaseWarnings(cwd, {
|
|
30
|
+
updatesVersion: !options.changelogOnly,
|
|
31
|
+
});
|
|
19
32
|
|
|
20
33
|
return {
|
|
21
34
|
version,
|
|
22
35
|
tag,
|
|
23
36
|
branch: await currentBranch(cwd),
|
|
24
37
|
remote: options.remote ?? 'origin',
|
|
25
|
-
|
|
38
|
+
previousTag: notes?.previousTag ?? null,
|
|
39
|
+
range: notes?.range ?? null,
|
|
40
|
+
files,
|
|
41
|
+
warnings,
|
|
42
|
+
actions: releaseActions({
|
|
43
|
+
version,
|
|
44
|
+
tag,
|
|
45
|
+
skipCommit: options.skipCommit || options.versionOnly || options.changelogOnly,
|
|
46
|
+
versionOnly: options.versionOnly,
|
|
47
|
+
changelogOnly: options.changelogOnly,
|
|
48
|
+
files,
|
|
49
|
+
}),
|
|
26
50
|
};
|
|
27
51
|
}
|
|
28
52
|
|
|
@@ -35,11 +59,19 @@ export async function runRelease(options = {}) {
|
|
|
35
59
|
}
|
|
36
60
|
|
|
37
61
|
await assertCleanWorktree(cwd);
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
62
|
+
if (!options.skipCommit && !options.versionOnly && !options.changelogOnly) {
|
|
63
|
+
await assertTagAvailable(cwd, plan.tag);
|
|
64
|
+
}
|
|
41
65
|
|
|
42
|
-
if (options.
|
|
66
|
+
if (!options.changelogOnly) {
|
|
67
|
+
await updatePackageVersion(cwd, plan.version);
|
|
68
|
+
await updatePackageLock(cwd, plan.version);
|
|
69
|
+
}
|
|
70
|
+
if (!options.versionOnly) {
|
|
71
|
+
await prependChangelog(cwd, plan.version, options);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (options.skipCommit || options.versionOnly || options.changelogOnly) {
|
|
43
75
|
return { ...plan, tag: null };
|
|
44
76
|
}
|
|
45
77
|
|
|
@@ -48,18 +80,24 @@ export async function runRelease(options = {}) {
|
|
|
48
80
|
try {
|
|
49
81
|
await git(cwd, ['tag', '-a', plan.tag, '-m', `Release ${plan.version}`]);
|
|
50
82
|
} catch (error) {
|
|
51
|
-
await git(cwd, ['reset', '--
|
|
52
|
-
throw new Error(`Failed to create git tag ${plan.tag};
|
|
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}`);
|
|
53
85
|
}
|
|
54
86
|
|
|
55
87
|
return plan;
|
|
56
88
|
}
|
|
57
89
|
|
|
58
|
-
function releaseActions({ version, tag, skipCommit = false }) {
|
|
59
|
-
const actions = [
|
|
60
|
-
|
|
61
|
-
`
|
|
62
|
-
|
|
90
|
+
function releaseActions({ version, tag, skipCommit = false, versionOnly = false, changelogOnly = false, files = [] }) {
|
|
91
|
+
const actions = [];
|
|
92
|
+
if (!changelogOnly) {
|
|
93
|
+
actions.push(`update package.json version to ${version}`);
|
|
94
|
+
if (files.some((file) => ['package-lock.json', 'npm-shrinkwrap.json'].includes(file))) {
|
|
95
|
+
actions.push('update npm lockfile version metadata');
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (!versionOnly) {
|
|
99
|
+
actions.push(`prepend CHANGELOG.md entry for ${version}`);
|
|
100
|
+
}
|
|
63
101
|
if (!skipCommit) {
|
|
64
102
|
actions.push(`create git commit chore(release): ${version}`);
|
|
65
103
|
actions.push(`create git tag ${tag}`);
|
|
@@ -67,398 +105,25 @@ function releaseActions({ version, tag, skipCommit = false }) {
|
|
|
67
105
|
return actions;
|
|
68
106
|
}
|
|
69
107
|
|
|
70
|
-
async function
|
|
71
|
-
const
|
|
72
|
-
const pkg = JSON.parse(await readFile(packagePath, 'utf8'));
|
|
73
|
-
pkg.version = version;
|
|
74
|
-
await writeFile(packagePath, `${JSON.stringify(pkg, null, 2)}\n`);
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
async function updatePackageLock(cwd, version) {
|
|
78
|
-
for (const fileName of ['package-lock.json', 'npm-shrinkwrap.json']) {
|
|
79
|
-
const filePath = path.join(cwd, fileName);
|
|
80
|
-
let lock;
|
|
81
|
-
try {
|
|
82
|
-
lock = JSON.parse(await readFile(filePath, 'utf8'));
|
|
83
|
-
} catch (error) {
|
|
84
|
-
if (error.code === 'ENOENT') continue;
|
|
85
|
-
throw error;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
if (typeof lock.version === 'string') {
|
|
89
|
-
lock.version = version;
|
|
90
|
-
}
|
|
91
|
-
if (lock.packages?.[''] && typeof lock.packages[''].version === 'string') {
|
|
92
|
-
lock.packages[''].version = version;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
await writeFile(filePath, `${JSON.stringify(lock, null, 2)}\n`);
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
async function releaseFiles(cwd) {
|
|
100
|
-
const candidates = ['package.json', 'package-lock.json', 'npm-shrinkwrap.json', 'CHANGELOG.md'];
|
|
101
|
-
const files = [];
|
|
102
|
-
for (const candidate of candidates) {
|
|
103
|
-
if (await fileExists(path.join(cwd, candidate))) {
|
|
104
|
-
files.push(candidate);
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
return files;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
async function fileExists(filePath) {
|
|
111
|
-
try {
|
|
112
|
-
await access(filePath);
|
|
113
|
-
return true;
|
|
114
|
-
} catch {
|
|
115
|
-
return false;
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
async function prependChangelog(cwd, version, date, options = {}) {
|
|
120
|
-
const changelogPath = path.join(cwd, 'CHANGELOG.md');
|
|
121
|
-
let existing = '';
|
|
108
|
+
async function prependChangelog(cwd, version, options = {}) {
|
|
109
|
+
const existing = await readChangelog(cwd);
|
|
122
110
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
const notes = await releaseNotes(cwd, { ...options, existingChangelog: existing });
|
|
111
|
+
const notes = await releaseNotes(cwd, {
|
|
112
|
+
...options,
|
|
113
|
+
existingChangelog: existing,
|
|
114
|
+
tag: `${options.tagPrefix ?? ''}${version}`,
|
|
115
|
+
});
|
|
130
116
|
const heading = formatReleaseHeading({
|
|
131
117
|
version,
|
|
132
118
|
previousTag: notes.previousTag,
|
|
133
|
-
tag: `${options.tagPrefix
|
|
119
|
+
tag: options.tagPrefix ? `${options.tagPrefix}${version}` : version,
|
|
134
120
|
compareUrlBuilder: notes.compareUrlBuilder,
|
|
135
121
|
});
|
|
136
|
-
const entry = `${heading}\n\n${formatReleaseNotes(notes.changes)}${formatFullChangelog(notes.requests)}\n`;
|
|
122
|
+
const entry = `${heading}\n\n${formatReleaseNotes(notes.changes, options.changelogSections)}${formatFullChangelog(notes.requests)}\n`;
|
|
137
123
|
|
|
138
124
|
const body = existing.trim().startsWith('# Changelog')
|
|
139
125
|
? existing.replace(/^# Changelog\s*/, `# Changelog\n\n${entry}\n`)
|
|
140
126
|
: `# Changelog\n\n${entry}\n${existing}`;
|
|
141
127
|
|
|
142
|
-
await
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
async function releaseNotes(cwd, options = {}) {
|
|
146
|
-
await fetchTags(cwd, options.remote ?? 'origin');
|
|
147
|
-
const latestTag = await latestReleaseTag(cwd, options.existingChangelog ?? '');
|
|
148
|
-
const range = latestTag ? [`${latestTag}..HEAD`] : [];
|
|
149
|
-
const commits = await gitCommits(cwd, range);
|
|
150
|
-
const remoteUrl = await getRemoteUrl(cwd, options.remote ?? 'origin');
|
|
151
|
-
const commitUrlBuilder = remoteUrl ? buildCommitUrlBuilder(remoteUrl) : null;
|
|
152
|
-
const compareUrlBuilder = remoteUrl ? buildCompareUrlBuilder(remoteUrl) : null;
|
|
153
|
-
const requestUrlBuilder = remoteUrl ? buildRequestUrlBuilder(remoteUrl) : null;
|
|
154
|
-
const allowedTypes = options.types ?? ['feat', 'fix', 'docs', 'style', 'refactor', 'perf', 'test', 'build', 'ci', 'chore', 'revert'];
|
|
155
|
-
const conventionalCommits = dedupeConventionalChanges(commits
|
|
156
|
-
.map((commit) => ({ ...commit, subject: conventionalSubjectForCommit(commit) ?? commit.subject }))
|
|
157
|
-
.filter((commit) => isConventionalCommit(commit.subject))
|
|
158
|
-
.filter((commit) => allowedTypes.includes(conventionalType(commit.subject)))
|
|
159
|
-
.filter((commit) => !isCommitInChangelog(commit, options.existingChangelog ?? ''))
|
|
160
|
-
.map((commit) => ({
|
|
161
|
-
...commit,
|
|
162
|
-
request: requestForCommit(commit, requestUrlBuilder),
|
|
163
|
-
url: commitUrlBuilder ? commitUrlBuilder(commit.hash) : null,
|
|
164
|
-
})));
|
|
165
|
-
const requests = uniqueRequests(commits.map((commit) => requestForCommit(commit, requestUrlBuilder)).filter(Boolean));
|
|
166
|
-
return {
|
|
167
|
-
previousTag: latestTag,
|
|
168
|
-
compareUrlBuilder,
|
|
169
|
-
changes: conventionalCommits.length > 0 ? conventionalCommits : ['No conventional commits in this release.'],
|
|
170
|
-
requests,
|
|
171
|
-
};
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
function formatReleaseHeading({ version, previousTag, tag, compareUrlBuilder }) {
|
|
175
|
-
const label = previousTag && compareUrlBuilder
|
|
176
|
-
? `[${version}](${compareUrlBuilder(previousTag, tag)})`
|
|
177
|
-
: version;
|
|
178
|
-
return `## ${label}`;
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
function isCommitInChangelog(commit, changelog) {
|
|
182
|
-
if (!changelog) return false;
|
|
183
|
-
return changelog.includes(commit.hash) || changelog.includes(commit.hash.slice(0, 7));
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
function isConventionalCommit(subject) {
|
|
187
|
-
return /^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\([^)]+\))?!?: .+/.test(subject);
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
function conventionalSubjectForCommit(commit) {
|
|
191
|
-
return commitLines(commit).find((line) => isConventionalCommit(line)) ?? null;
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
function commitLines(commit) {
|
|
195
|
-
return [commit.subject, ...(commit.body ?? '').split('\n')]
|
|
196
|
-
.map((line) => line.trim())
|
|
197
|
-
.filter(Boolean);
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
function dedupeConventionalChanges(changes) {
|
|
201
|
-
const deduped = [];
|
|
202
|
-
for (const change of changes) {
|
|
203
|
-
const existingIndex = deduped.findIndex((candidate) => candidate.subject === change.subject);
|
|
204
|
-
if (existingIndex < 0) {
|
|
205
|
-
deduped.push(change);
|
|
206
|
-
continue;
|
|
207
|
-
}
|
|
208
|
-
if (!deduped[existingIndex].request && change.request) {
|
|
209
|
-
deduped[existingIndex] = change;
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
return deduped;
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
function formatReleaseNotes(changes) {
|
|
216
|
-
if (changes.length === 1 && changes[0] === 'No conventional commits in this release.') {
|
|
217
|
-
return `- ${changes[0]}`;
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
const features = changes.filter((change) => conventionalType(change.subject) === 'feat');
|
|
221
|
-
const fixes = changes.filter((change) => conventionalType(change.subject) === 'fix');
|
|
222
|
-
const other = changes.filter((change) => !['feat', 'fix'].includes(conventionalType(change.subject)));
|
|
223
|
-
const sections = [
|
|
224
|
-
['Features', features],
|
|
225
|
-
['Fixes', fixes],
|
|
226
|
-
['Other Changes', other],
|
|
227
|
-
];
|
|
228
|
-
|
|
229
|
-
return sections
|
|
230
|
-
.filter(([, entries]) => entries.length > 0)
|
|
231
|
-
.map(([heading, entries]) => `### ${heading}\n\n${entries.map((entry) => `- ${formatCommitEntry(entry)}`).join('\n')}`)
|
|
232
|
-
.join('\n\n');
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
function formatFullChangelog(requests) {
|
|
236
|
-
if (requests.length === 0) {
|
|
237
|
-
return '';
|
|
238
|
-
}
|
|
239
|
-
return `\n\n### Full Changelog\n\n${requests.map((request) => `- ${formatRequestEntry(request)}`).join('\n')}`;
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
function conventionalType(subject) {
|
|
243
|
-
return /^(?<type>[a-z]+)(\([^)]+\))?!?: .+/.exec(subject)?.groups.type;
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
function formatCommitEntry(commit) {
|
|
247
|
-
const shortHash = commit.hash.slice(0, 7);
|
|
248
|
-
const suffix = commit.request
|
|
249
|
-
? ` (${formatRequestLink(commit.request)})`
|
|
250
|
-
: commit.url ? ` ([${shortHash}](${commit.url}))` : ` (${shortHash})`;
|
|
251
|
-
return `${commit.subject}${suffix}`;
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
function formatRequestLink(request) {
|
|
255
|
-
return request.url ? `[${request.label}](${request.url})` : request.label;
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
function formatRequestEntry(request) {
|
|
259
|
-
return request.title ? `${formatRequestLink(request)} ${request.title}` : formatRequestLink(request);
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
async function gitCommits(cwd, range) {
|
|
263
|
-
const { stdout } = await git(cwd, ['log', '--pretty=format:%H%x00%s%x00%B%x1e', ...range]);
|
|
264
|
-
return stdout
|
|
265
|
-
.split('\x1e')
|
|
266
|
-
.filter(Boolean)
|
|
267
|
-
.map((record) => {
|
|
268
|
-
const [hash, subject, body = ''] = record.replace(/^\n+|\n+$/g, '').split('\0');
|
|
269
|
-
return { hash, subject, body };
|
|
270
|
-
});
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
async function getRemoteUrl(cwd, remote) {
|
|
274
|
-
try {
|
|
275
|
-
const { stdout } = await git(cwd, ['remote', 'get-url', remote]);
|
|
276
|
-
return stdout.trim();
|
|
277
|
-
} catch {
|
|
278
|
-
return null;
|
|
279
|
-
}
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
function buildCommitUrlBuilder(remote) {
|
|
283
|
-
const parsed = parseGitRemote(remote);
|
|
284
|
-
if (!parsed) {
|
|
285
|
-
return null;
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
const baseUrl = `https://${parsed.host}/${parsed.repo}`;
|
|
289
|
-
if (parsed.host === 'github.com') {
|
|
290
|
-
return (hash) => `${baseUrl}/commit/${hash}`;
|
|
291
|
-
}
|
|
292
|
-
if (parsed.host.includes('gitlab')) {
|
|
293
|
-
return (hash) => `${baseUrl}/-/commit/${hash}`;
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
return null;
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
function buildCompareUrlBuilder(remote) {
|
|
300
|
-
const parsed = parseGitRemote(remote);
|
|
301
|
-
if (!parsed) {
|
|
302
|
-
return null;
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
const baseUrl = `https://${parsed.host}/${parsed.repo}`;
|
|
306
|
-
if (parsed.host === 'github.com') {
|
|
307
|
-
return (from, to) => `${baseUrl}/compare/${from}...${to}`;
|
|
308
|
-
}
|
|
309
|
-
if (parsed.host.includes('gitlab')) {
|
|
310
|
-
return (from, to) => `${baseUrl}/-/compare/${from}...${to}`;
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
return null;
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
function buildRequestUrlBuilder(remote) {
|
|
317
|
-
const parsed = parseGitRemote(remote);
|
|
318
|
-
if (!parsed) {
|
|
319
|
-
return null;
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
const baseUrl = `https://${parsed.host}/${parsed.repo}`;
|
|
323
|
-
if (parsed.host === 'github.com') {
|
|
324
|
-
return (request) => request.provider === 'github' ? `${baseUrl}/pull/${request.number}` : null;
|
|
325
|
-
}
|
|
326
|
-
if (parsed.host.includes('gitlab')) {
|
|
327
|
-
return (request) => request.provider === 'gitlab' ? `${baseUrl}/-/merge_requests/${request.number}` : null;
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
return null;
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
function requestForCommit(commit, requestUrlBuilder) {
|
|
334
|
-
const request = parseRequestReference(`${commit.subject}\n${commit.body ?? ''}`);
|
|
335
|
-
if (!request) {
|
|
336
|
-
return null;
|
|
337
|
-
}
|
|
338
|
-
return {
|
|
339
|
-
...request,
|
|
340
|
-
title: requestTitleForCommit(commit),
|
|
341
|
-
url: requestUrlBuilder ? requestUrlBuilder(request) : null,
|
|
342
|
-
};
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
function requestTitleForCommit(commit) {
|
|
346
|
-
const conventionalSubject = conventionalSubjectForCommit(commit);
|
|
347
|
-
if (conventionalSubject) {
|
|
348
|
-
return conventionalSubject;
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
return commitLines(commit)
|
|
352
|
-
.find((line) => line && !parseRequestReference(line) && !/^Merge\b/i.test(line)) ?? null;
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
function parseRequestReference(message) {
|
|
356
|
-
const gitlabMerge = /(?:^|\s)(?:See merge request\s+\S+!|!)(?<number>\d+)(?=\D|$)/i.exec(message);
|
|
357
|
-
if (gitlabMerge) {
|
|
358
|
-
return { provider: 'gitlab', number: gitlabMerge.groups.number, label: `!${gitlabMerge.groups.number}` };
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
const githubPull = /(?:Merge pull request\s+#|#)(?<number>\d+)(?=\D|$)/i.exec(message);
|
|
362
|
-
if (githubPull) {
|
|
363
|
-
return { provider: 'github', number: githubPull.groups.number, label: `#${githubPull.groups.number}` };
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
return null;
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
function uniqueRequests(requests) {
|
|
370
|
-
const seen = new Set();
|
|
371
|
-
const unique = [];
|
|
372
|
-
for (const request of requests) {
|
|
373
|
-
const key = `${request.provider}:${request.number}`;
|
|
374
|
-
if (seen.has(key)) {
|
|
375
|
-
const existing = unique.find((candidate) => `${candidate.provider}:${candidate.number}` === key);
|
|
376
|
-
if (existing && !existing.title && request.title) {
|
|
377
|
-
existing.title = request.title;
|
|
378
|
-
}
|
|
379
|
-
continue;
|
|
380
|
-
}
|
|
381
|
-
seen.add(key);
|
|
382
|
-
unique.push(request);
|
|
383
|
-
}
|
|
384
|
-
return unique;
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
function parseGitRemote(remote) {
|
|
388
|
-
const sshMatch = /^git@(?<host>[^:]+):(?<repo>.+?)(?:\.git)?$/.exec(remote);
|
|
389
|
-
if (sshMatch) {
|
|
390
|
-
return sshMatch.groups;
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
const httpsMatch = /^https:\/\/(?<host>[^/]+)\/(?<repo>.+?)(?:\.git)?$/.exec(remote);
|
|
394
|
-
if (httpsMatch) {
|
|
395
|
-
return httpsMatch.groups;
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
return null;
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
async function latestReleaseTag(cwd, changelog) {
|
|
402
|
-
const changelogTag = latestChangelogCompareTarget(changelog);
|
|
403
|
-
if (changelogTag && await tagExists(cwd, changelogTag)) {
|
|
404
|
-
return changelogTag;
|
|
405
|
-
}
|
|
406
|
-
return latestReachableTag(cwd);
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
function latestChangelogCompareTarget(changelog) {
|
|
410
|
-
const match = /^## \[[^\]]+\]\([^)]*\/(?:-\/)?compare\/[^)]*?\.{3}(?<tag>[^)\s]+)\)/m.exec(changelog);
|
|
411
|
-
return match?.groups.tag ?? null;
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
async function tagExists(cwd, tag) {
|
|
415
|
-
try {
|
|
416
|
-
await git(cwd, ['rev-parse', '--verify', '--quiet', `refs/tags/${tag}^{}`]);
|
|
417
|
-
return true;
|
|
418
|
-
} catch {
|
|
419
|
-
return false;
|
|
420
|
-
}
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
async function latestReachableTag(cwd) {
|
|
424
|
-
try {
|
|
425
|
-
const { stdout } = await git(cwd, ['describe', '--tags', '--abbrev=0']);
|
|
426
|
-
return stdout.trim() || null;
|
|
427
|
-
} catch {
|
|
428
|
-
return null;
|
|
429
|
-
}
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
async function fetchTags(cwd, remote) {
|
|
433
|
-
try {
|
|
434
|
-
await git(cwd, ['remote', 'get-url', remote]);
|
|
435
|
-
await git(cwd, ['fetch', '--tags', remote]);
|
|
436
|
-
} catch {
|
|
437
|
-
// Local/offline repos can still release using tags already present.
|
|
438
|
-
}
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
async function currentBranch(cwd) {
|
|
442
|
-
try {
|
|
443
|
-
const { stdout } = await git(cwd, ['branch', '--show-current']);
|
|
444
|
-
return stdout.trim() || 'HEAD';
|
|
445
|
-
} catch {
|
|
446
|
-
return 'HEAD';
|
|
447
|
-
}
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
async function assertCleanWorktree(cwd) {
|
|
451
|
-
const status = await gitLines(cwd, ['status', '--porcelain']);
|
|
452
|
-
if (status.length > 0) {
|
|
453
|
-
throw new Error('Working tree is not clean. Commit or stash changes before releasing.');
|
|
454
|
-
}
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
async function gitLines(cwd, args) {
|
|
458
|
-
const { stdout } = await git(cwd, args);
|
|
459
|
-
return stdout.split('\n').map((line) => line.trim()).filter(Boolean);
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
async function git(cwd, args) {
|
|
463
|
-
return execFile('git', args, { cwd, encoding: 'utf8' });
|
|
128
|
+
await writeChangelog(cwd, body);
|
|
464
129
|
}
|
package/test/calver.test.js
DELETED
|
@@ -1,69 +0,0 @@
|
|
|
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 YY.MMDD format for the first release of the day', () => {
|
|
7
|
-
const version = nextCalVer({
|
|
8
|
-
date: new Date('2026-05-29T12:00:00-07:00'),
|
|
9
|
-
existingTags: [],
|
|
10
|
-
});
|
|
11
|
-
|
|
12
|
-
assert.equal(version, '26.0529');
|
|
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: ['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',
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
assert.equal(version, '260529');
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
test('nextCalVer can emit compact YYMMDD.N after the first release of the day', () => {
|
|
43
|
-
const version = nextCalVer({
|
|
44
|
-
date: new Date('2026-05-29T12:00:00-07:00'),
|
|
45
|
-
existingTags: ['260529'],
|
|
46
|
-
format: 'compact',
|
|
47
|
-
});
|
|
48
|
-
|
|
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');
|
|
69
|
-
});
|