@proteinjs/build 1.9.2 → 2.0.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/dist/src/versionWorkspace.d.ts +23 -0
- package/dist/src/versionWorkspace.d.ts.map +1 -1
- package/dist/src/versionWorkspace.js +357 -91
- package/dist/src/versionWorkspace.js.map +1 -1
- package/dist/test/classifyCommitMessage.test.d.ts +2 -0
- package/dist/test/classifyCommitMessage.test.d.ts.map +1 -0
- package/dist/test/classifyCommitMessage.test.js +75 -0
- package/dist/test/classifyCommitMessage.test.js.map +1 -0
- package/package.json +2 -3
- package/src/versionWorkspace.ts +304 -32
- package/test/classifyCommitMessage.test.ts +95 -0
- package/LICENSE +0 -21
package/src/versionWorkspace.ts
CHANGED
|
@@ -13,8 +13,14 @@ const fixedVersionWorkspacesToVersion: { [workspacePath: string]: boolean } = {}
|
|
|
13
13
|
|
|
14
14
|
export async function versionWorkspace() {
|
|
15
15
|
const dryRun = isDryRun();
|
|
16
|
+
const planOnly = isPlanOnly();
|
|
16
17
|
|
|
17
|
-
if (
|
|
18
|
+
if (planOnly) {
|
|
19
|
+
logger.info({
|
|
20
|
+
message:
|
|
21
|
+
'Plan-only mode enabled. Scan + commit-leaves + would-be bumps will be computed and logged; nothing will be written to disk, built, tested, published, committed, or pushed.',
|
|
22
|
+
});
|
|
23
|
+
} else if (dryRun) {
|
|
18
24
|
logger.info({ message: 'Dry run mode enabled. Publish and push operations will be skipped.' });
|
|
19
25
|
}
|
|
20
26
|
const workspacePath = process.cwd();
|
|
@@ -23,8 +29,8 @@ export async function versionWorkspace() {
|
|
|
23
29
|
if (workspaceRootDirty) {
|
|
24
30
|
logger.info({ message: `> Workspace root is dirty, will skip pull/push for root repo` });
|
|
25
31
|
}
|
|
26
|
-
if (dryRun) {
|
|
27
|
-
logger.info({ message: `>
|
|
32
|
+
if (dryRun || planOnly) {
|
|
33
|
+
logger.info({ message: `> Skipping pullWorkspace for (${workspacePath})` });
|
|
28
34
|
} else {
|
|
29
35
|
await pullWorkspace(workspacePath, workspaceRootDirty);
|
|
30
36
|
}
|
|
@@ -42,22 +48,91 @@ export async function versionWorkspace() {
|
|
|
42
48
|
});
|
|
43
49
|
|
|
44
50
|
logger.info({ message: `> Versioning workspace (${workspacePath})` });
|
|
51
|
+
|
|
52
|
+
// Phase 0: scan every candidate package's unpushed commits up front. This
|
|
53
|
+
// gives us a map of packages that have their own local changes to ship,
|
|
54
|
+
// separate from the traditional "dependency bumped, cascade" trigger.
|
|
55
|
+
const commitBumps = await scanCommitBumps(filteredPackageNames, packageMap);
|
|
56
|
+
if (commitBumps.size === 0) {
|
|
57
|
+
logger.info({ message: `> No packages have unpushed commits` });
|
|
58
|
+
} else {
|
|
59
|
+
const scanSummary = Array.from(commitBumps.entries())
|
|
60
|
+
.map(([name, bump]) => `${cw.color(name)}:${bump}`)
|
|
61
|
+
.join(', ');
|
|
62
|
+
logger.info({ message: `> Packages with unpushed commits: ${scanSummary}` });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Phase 1: identify commit-leaves — packages with unpushed commits whose
|
|
66
|
+
// direct workspace-local deps have none. These are the roots of change
|
|
67
|
+
// and must be versioned+published first; once they're out, the cascade of
|
|
68
|
+
// dep-version rewrites propagates to the rest.
|
|
69
|
+
const commitLeaves = computeCommitLeaves(commitBumps, packageGraph, filteredPackageNames);
|
|
70
|
+
if (commitLeaves.length > 0) {
|
|
71
|
+
logger.info({
|
|
72
|
+
message: `> Commit-leaves (root changes, will publish first): ${commitLeaves.map((n) => cw.color(n)).join(', ')}`,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Phase 2: unified topo-ordered loop. For each package we combine two
|
|
77
|
+
// signals: own unpushed commits (from `commitBumps`) and dep-version
|
|
78
|
+
// rewrites (from `applyDependencyVersionRewrites`). A package publishes
|
|
79
|
+
// iff either signal fires. The effective bump is the max of (own-commit
|
|
80
|
+
// bump) and (cascade → 'patch'). Topo order (deps-first) ensures leaves
|
|
81
|
+
// publish before dependents that need to consume their new versions, and
|
|
82
|
+
// any non-leaf commit-haver still gets its own-commit bump respected
|
|
83
|
+
// rather than being demoted to 'patch' by the cascade.
|
|
45
84
|
for (const packageName of filteredPackageNames) {
|
|
46
85
|
const localPackage = packageMap[packageName];
|
|
47
86
|
const skipBumpingPackageVersion = isInFixedVersionWorkspace(localPackage);
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
)
|
|
54
|
-
if (!dependenciesChanged) {
|
|
87
|
+
const ownBump = skipBumpingPackageVersion ? undefined : commitBumps.get(packageName);
|
|
88
|
+
const dependenciesChanged = await applyDependencyVersionRewrites(localPackage, packageMap, packageGraph);
|
|
89
|
+
const cascadeBump: CommitBump | undefined = dependenciesChanged ? 'patch' : undefined;
|
|
90
|
+
const effectiveBump = maxBump(ownBump, cascadeBump);
|
|
91
|
+
|
|
92
|
+
if (!effectiveBump && !dependenciesChanged) {
|
|
55
93
|
continue;
|
|
56
94
|
}
|
|
57
95
|
|
|
58
|
-
|
|
96
|
+
if (effectiveBump && !skipBumpingPackageVersion) {
|
|
97
|
+
const currentVersion = localPackage.packageJson.version;
|
|
98
|
+
// In-memory version mutation runs in plan-only too so downstream
|
|
99
|
+
// packages see the right dep versions when we simulate cascades.
|
|
100
|
+
localPackage.packageJson.version = semver.inc(currentVersion, effectiveBump);
|
|
101
|
+
const sourceNote = ownBump
|
|
102
|
+
? cascadeBump
|
|
103
|
+
? `own+cascade, own=${ownBump}`
|
|
104
|
+
: `own=${ownBump}`
|
|
105
|
+
: 'dep cascade';
|
|
106
|
+
const planPrefix = planOnly ? 'would bump' : 'bumping';
|
|
107
|
+
logger.info({
|
|
108
|
+
message: `(${cw.color(packageName)}) ${planPrefix} version (${effectiveBump}; ${sourceNote}) from ${currentVersion} -> ${localPackage.packageJson.version}`,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Fixed-version workspace tracking: record even in plan-only so we can
|
|
113
|
+
// report the would-be synced version below. This is metadata only — the
|
|
114
|
+
// actual sync (disk write, commit, push) is gated by the planOnly /
|
|
115
|
+
// dryRun guards after the loop.
|
|
59
116
|
if (isInFixedVersionWorkspace(localPackage) && localPackage.workspace) {
|
|
60
117
|
fixedVersionWorkspacesToVersion[localPackage.workspace.path] = true;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (planOnly) {
|
|
121
|
+
// Plan-only: skip the disk write, the build/test, publish, push/tag,
|
|
122
|
+
// and the eventual syncFixedVersionWorkspaces pass. The in-memory
|
|
123
|
+
// mutation above is enough to keep the simulated cascade correct for
|
|
124
|
+
// the remaining packages in this loop.
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (effectiveBump || dependenciesChanged) {
|
|
129
|
+
await Fs.writeFiles([
|
|
130
|
+
{ path: localPackage.filePath, content: JSON.stringify(localPackage.packageJson, null, 2) },
|
|
131
|
+
]);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
await buildAndTest(localPackage);
|
|
135
|
+
if (isInFixedVersionWorkspace(localPackage) && localPackage.workspace) {
|
|
61
136
|
logger.info({
|
|
62
137
|
message: `(${cw.color(packageName)}) skipping version push for package in a fixed-version workspace`,
|
|
63
138
|
});
|
|
@@ -71,6 +146,13 @@ export async function versionWorkspace() {
|
|
|
71
146
|
await pushAndTag(localPackage);
|
|
72
147
|
}
|
|
73
148
|
|
|
149
|
+
if (planOnly) {
|
|
150
|
+
await logPlannedFixedVersionSyncs(Object.keys(fixedVersionWorkspacesToVersion), packageMap, workspaceToPackageMap);
|
|
151
|
+
logger.info({ message: `> Plan-only: skipping fixed-version sync, metarepo push, and symlink refresh` });
|
|
152
|
+
logger.info({ message: `> Finished versioning workspace (${workspacePath}) [plan-only]` });
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
|
|
74
156
|
const pushWithoutSync = true;
|
|
75
157
|
await syncFixedVersionWorkspaces(
|
|
76
158
|
Object.keys(fixedVersionWorkspacesToVersion),
|
|
@@ -97,6 +179,35 @@ function isDryRun() {
|
|
|
97
179
|
return false;
|
|
98
180
|
}
|
|
99
181
|
|
|
182
|
+
/**
|
|
183
|
+
* Plan-only mode: the strictest possible preview. Runs Phase 0 (commit-bump
|
|
184
|
+
* scan) and Phase 1 (commit-leaf detection) so you can see which packages
|
|
185
|
+
* would be published and at what level, then simulates the topo-ordered
|
|
186
|
+
* cascade in memory (for correct would-be bump reporting) but:
|
|
187
|
+
*
|
|
188
|
+
* - does NOT write any package.json or lerna.json to disk
|
|
189
|
+
* - does NOT run `npm install` / lint / build / test
|
|
190
|
+
* - does NOT publish, commit, push, tag, or refresh symlinks
|
|
191
|
+
*
|
|
192
|
+
* Intended for a fast preview of "what will this release do?" Implies
|
|
193
|
+
* dry-run for the few checks that need a binary answer (e.g. `publish`'s
|
|
194
|
+
* own dry-run guard), but the stricter plan-only guards in the main loop
|
|
195
|
+
* mean those code paths never execute in the first place.
|
|
196
|
+
*/
|
|
197
|
+
function isPlanOnly() {
|
|
198
|
+
const args = process.argv.slice(2);
|
|
199
|
+
if (args.includes('--plan-only') || args.includes('--planonly') || args.includes('--plan')) {
|
|
200
|
+
return true;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const envFlag = process.env.VERSION_WORKSPACE_PLAN_ONLY ?? process.env.PLAN_ONLY;
|
|
204
|
+
if (envFlag) {
|
|
205
|
+
return envFlag === 'true' || envFlag === '1';
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return false;
|
|
209
|
+
}
|
|
210
|
+
|
|
100
211
|
function isInFixedVersionWorkspace(localPackage: LocalPackage) {
|
|
101
212
|
return (
|
|
102
213
|
localPackage.workspace &&
|
|
@@ -155,12 +266,22 @@ async function pullWorkspace(workspacePath: string, skipRootRepo = false) {
|
|
|
155
266
|
logger.info({ message: `> Finished pulling workspace (${workspacePath})` });
|
|
156
267
|
}
|
|
157
268
|
|
|
158
|
-
|
|
269
|
+
/**
|
|
270
|
+
* Rewrite `localPackage`'s dependency-version fields in package.json to match
|
|
271
|
+
* the current in-memory versions of its workspace-local deps. Returns true if
|
|
272
|
+
* any dep version was rewritten (i.e. a dep's already-bumped version now
|
|
273
|
+
* differs from what this package.json records). Pure rewrite — does NOT bump
|
|
274
|
+
* `localPackage`'s own version; the caller decides that by combining this
|
|
275
|
+
* result with the commit-scan map (see `versionWorkspace`).
|
|
276
|
+
*
|
|
277
|
+
* Writing the package.json to disk is left to the caller too, so we can apply
|
|
278
|
+
* the own-version bump in the same write.
|
|
279
|
+
*/
|
|
280
|
+
async function applyDependencyVersionRewrites(
|
|
159
281
|
localPackage: LocalPackage,
|
|
160
282
|
packageMap: LocalPackageMap,
|
|
161
|
-
packageGraph: any
|
|
162
|
-
|
|
163
|
-
) {
|
|
283
|
+
packageGraph: any
|
|
284
|
+
): Promise<boolean> {
|
|
164
285
|
const localDependencies = packageGraph.successors(localPackage.name);
|
|
165
286
|
if (!localDependencies || localDependencies.length == 0) {
|
|
166
287
|
return false;
|
|
@@ -193,20 +314,63 @@ async function bumpDependencies(
|
|
|
193
314
|
dependenciesChanged = true;
|
|
194
315
|
}
|
|
195
316
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
317
|
+
return dependenciesChanged;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Up-front scan: for each candidate package, classify the unpushed commits
|
|
322
|
+
* in its own git repo into a semver bump level. Returns a map (only contains
|
|
323
|
+
* entries for packages with classifiable unpushed commits). Packages with no
|
|
324
|
+
* unpushed commits or missing upstream branches are absent from the map.
|
|
325
|
+
*
|
|
326
|
+
* This is the input to `computeCommitLeaves` and to the per-package bump-level
|
|
327
|
+
* combine in the main loop.
|
|
328
|
+
*/
|
|
329
|
+
async function scanCommitBumps(
|
|
330
|
+
packageNames: string[],
|
|
331
|
+
packageMap: LocalPackageMap
|
|
332
|
+
): Promise<Map<string, CommitBump>> {
|
|
333
|
+
const result = new Map<string, CommitBump>();
|
|
334
|
+
for (const packageName of packageNames) {
|
|
335
|
+
const localPackage = packageMap[packageName];
|
|
336
|
+
const packageDir = path.dirname(localPackage.filePath);
|
|
337
|
+
const bump = await classifyUnpushedCommits(packageDir);
|
|
338
|
+
if (bump) {
|
|
339
|
+
result.set(packageName, bump);
|
|
205
340
|
}
|
|
206
|
-
await Fs.writeFiles([{ path: localPackage.filePath, content: JSON.stringify(localPackage.packageJson, null, 2) }]);
|
|
207
341
|
}
|
|
342
|
+
return result;
|
|
343
|
+
}
|
|
208
344
|
|
|
209
|
-
|
|
345
|
+
/**
|
|
346
|
+
* A package is a "commit-leaf" if it has unpushed commits (i.e. appears in
|
|
347
|
+
* `scanMap`) AND none of its direct workspace-local deps have unpushed
|
|
348
|
+
* commits. These are the true sources of change — they trigger the entire
|
|
349
|
+
* cascade, so they must be versioned + published first.
|
|
350
|
+
*
|
|
351
|
+
* Direct deps only (not transitive): the topo-ordered main loop handles
|
|
352
|
+
* transitivity naturally by cascading dep-version rewrites forward.
|
|
353
|
+
*
|
|
354
|
+
* Returns leaves in topo order (deps-first) so independent leaves publish
|
|
355
|
+
* in a deterministic, dependency-respecting sequence.
|
|
356
|
+
*/
|
|
357
|
+
function computeCommitLeaves(
|
|
358
|
+
scanMap: Map<string, CommitBump>,
|
|
359
|
+
packageGraph: any,
|
|
360
|
+
sortedPackageNames: string[]
|
|
361
|
+
): string[] {
|
|
362
|
+
const leaves: string[] = [];
|
|
363
|
+
for (const packageName of sortedPackageNames) {
|
|
364
|
+
if (!scanMap.has(packageName)) {
|
|
365
|
+
continue;
|
|
366
|
+
}
|
|
367
|
+
const directDeps: string[] = packageGraph.successors(packageName) ?? [];
|
|
368
|
+
const anyDepHasCommits = directDeps.some((dep) => scanMap.has(dep));
|
|
369
|
+
if (!anyDepHasCommits) {
|
|
370
|
+
leaves.push(packageName);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
return leaves;
|
|
210
374
|
}
|
|
211
375
|
|
|
212
376
|
type DependencyVersion = { prefix?: string; version: string; isLocalPath?: boolean };
|
|
@@ -292,13 +456,54 @@ async function syncFixedVersionWorkspaces(
|
|
|
292
456
|
logger.info({ message: `> Synced fixed-version workspaces` });
|
|
293
457
|
}
|
|
294
458
|
|
|
459
|
+
/**
|
|
460
|
+
* Plan-only companion to `syncFixedVersionWorkspaces`: for each fixed-version
|
|
461
|
+
* workspace that would be touched, classify unpushed commits at the workspace
|
|
462
|
+
* root and log the version it would sync to. No disk writes, no git ops.
|
|
463
|
+
*/
|
|
464
|
+
async function logPlannedFixedVersionSyncs(
|
|
465
|
+
fixedVersionWorkspacePaths: string[],
|
|
466
|
+
packageMap: LocalPackageMap,
|
|
467
|
+
workspaceToPackageMap: { [workspacePath: string]: string[] }
|
|
468
|
+
) {
|
|
469
|
+
if (fixedVersionWorkspacePaths.length === 0) {
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
logger.info({ message: `> Fixed-version workspaces that would sync:` });
|
|
473
|
+
for (const workspacePath of fixedVersionWorkspacePaths) {
|
|
474
|
+
const workspacePackageNames = workspaceToPackageMap[workspacePath] ?? [];
|
|
475
|
+
const anyPackageInWs = workspacePackageNames.map((n) => packageMap[n]).find((p) => p?.workspace?.lernaJson);
|
|
476
|
+
const lernaJson = anyPackageInWs?.workspace?.lernaJson;
|
|
477
|
+
const repoName = path.basename(workspacePath.endsWith(path.sep) ? workspacePath.slice(0, -1) : workspacePath);
|
|
478
|
+
if (!lernaJson) {
|
|
479
|
+
logger.info({
|
|
480
|
+
message: ` (${cw.color(repoName)}) would sync fixed-version workspace (lerna.json not available; bump level unknown)`,
|
|
481
|
+
});
|
|
482
|
+
continue;
|
|
483
|
+
}
|
|
484
|
+
// Mirror `syncFixedVersions`: classify commits at the workspace root;
|
|
485
|
+
// fall back to 'patch' when there are no classifiable unpushed commits
|
|
486
|
+
// (the sync was triggered purely by a sub-package's dep-version rewrite).
|
|
487
|
+
const bump = (await classifyUnpushedCommits(workspacePath)) ?? 'patch';
|
|
488
|
+
const wouldBeVersion = semver.inc(lernaJson.version, bump);
|
|
489
|
+
logger.info({
|
|
490
|
+
message: ` (${cw.color(repoName)}) would sync fixed-version workspace ${lernaJson.version} -> ${wouldBeVersion} (${bump})`,
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
295
495
|
async function syncFixedVersions(workspacePath: string, localPackages: LocalPackage[]): Promise<string | false> {
|
|
296
496
|
const lernaJson = localPackages[0].workspace?.lernaJson;
|
|
297
497
|
if (!lernaJson) {
|
|
298
498
|
throw new Error(`Cannot find lerna.json for workspace: ${workspacePath}`);
|
|
299
499
|
}
|
|
300
500
|
|
|
301
|
-
|
|
501
|
+
// Fixed-version workspaces bump at the workspace-root level; use the
|
|
502
|
+
// net-bump classifier so a `feat!`/`BREAKING CHANGE` in any sub-package
|
|
503
|
+
// promotes the whole workspace to major. Fall back to patch when there
|
|
504
|
+
// are no classifiable unpushed commits (i.e. nothing to publish, but we
|
|
505
|
+
// were called because dep rewrites on a sub-package triggered the sync).
|
|
506
|
+
const bump = (await classifyUnpushedCommits(workspacePath)) ?? 'patch';
|
|
302
507
|
const highestVersion = semver.inc(lernaJson.version, bump);
|
|
303
508
|
if (!highestVersion) {
|
|
304
509
|
throw new Error(`Lerna version not specified for workspace: ${workspacePath}`);
|
|
@@ -748,19 +953,86 @@ async function hasPendingChanges(dir: string): Promise<boolean> {
|
|
|
748
953
|
});
|
|
749
954
|
}
|
|
750
955
|
|
|
751
|
-
|
|
956
|
+
export type CommitBump = 'major' | 'minor' | 'patch';
|
|
957
|
+
|
|
958
|
+
const BUMP_ORDER: Record<CommitBump, number> = { patch: 1, minor: 2, major: 3 };
|
|
959
|
+
|
|
960
|
+
function maxBump(a: CommitBump | undefined, b: CommitBump | undefined): CommitBump | undefined {
|
|
961
|
+
if (!a) {
|
|
962
|
+
return b;
|
|
963
|
+
}
|
|
964
|
+
if (!b) {
|
|
965
|
+
return a;
|
|
966
|
+
}
|
|
967
|
+
return BUMP_ORDER[a] >= BUMP_ORDER[b] ? a : b;
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
/**
|
|
971
|
+
* Classify the net semver bump implied by the set of commits in HEAD that
|
|
972
|
+
* aren't yet on the tracked upstream branch (`git log @{u}..HEAD`).
|
|
973
|
+
*
|
|
974
|
+
* - `major`: any commit declares `BREAKING CHANGE` in subject/body, or
|
|
975
|
+
* uses the conventional-commits `!` marker (`feat!:`,
|
|
976
|
+
* `fix(scope)!:`, etc.).
|
|
977
|
+
* - `minor`: any commit is a `feat` (type, optionally scoped, no `!`).
|
|
978
|
+
* - `patch`: any other commit is present (fix/chore/refactor/docs/…).
|
|
979
|
+
* - `undefined`: no unpushed commits, OR no tracked upstream (the `@{u}`
|
|
980
|
+
* shorthand errors silently — treated as "nothing to ship").
|
|
981
|
+
*
|
|
982
|
+
* Uses `%B` (full body) separated by a null byte so footer-style
|
|
983
|
+
* `BREAKING CHANGE:` lines are visible. Shells out to `git` directly so we
|
|
984
|
+
* don't depend on any repo state beyond a valid upstream ref.
|
|
985
|
+
*/
|
|
986
|
+
export async function classifyUnpushedCommits(dir: string): Promise<CommitBump | undefined> {
|
|
752
987
|
return new Promise((resolve) => {
|
|
753
|
-
|
|
988
|
+
// `\x00` as record separator; each record is the full commit message body.
|
|
989
|
+
exec('git log @{u}..HEAD --format=%B%x00', { cwd: dir }, (error, stdout) => {
|
|
754
990
|
if (error || !stdout.trim()) {
|
|
755
|
-
resolve(
|
|
991
|
+
resolve(undefined);
|
|
756
992
|
return;
|
|
757
993
|
}
|
|
758
|
-
const
|
|
759
|
-
|
|
994
|
+
const commits = stdout.split('\x00').map((s) => s.trim()).filter((s) => s.length > 0);
|
|
995
|
+
let result: CommitBump | undefined;
|
|
996
|
+
for (const commit of commits) {
|
|
997
|
+
result = maxBump(result, classifyCommitMessage(commit));
|
|
998
|
+
if (result === 'major') {
|
|
999
|
+
break;
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
resolve(result);
|
|
760
1003
|
});
|
|
761
1004
|
});
|
|
762
1005
|
}
|
|
763
1006
|
|
|
1007
|
+
/**
|
|
1008
|
+
* Classify a single commit message by conventional-commits rules. Exported
|
|
1009
|
+
* so unit tests can exercise it without spawning git.
|
|
1010
|
+
*/
|
|
1011
|
+
export function classifyCommitMessage(message: string): CommitBump | undefined {
|
|
1012
|
+
if (!message.trim()) {
|
|
1013
|
+
return undefined;
|
|
1014
|
+
}
|
|
1015
|
+
const [subject, ...rest] = message.split('\n');
|
|
1016
|
+
const body = rest.join('\n');
|
|
1017
|
+
|
|
1018
|
+
// Breaking-change markers. `type!:` / `type(scope)!:` on the subject,
|
|
1019
|
+
// or `BREAKING CHANGE:` / `BREAKING-CHANGE:` anywhere (subject or body/footer).
|
|
1020
|
+
if (/^\w+(\([^)]*\))?!:/.test(subject)) {
|
|
1021
|
+
return 'major';
|
|
1022
|
+
}
|
|
1023
|
+
if (/BREAKING[ -]CHANGE\s*:/i.test(`${subject}\n${body}`)) {
|
|
1024
|
+
return 'major';
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
// `feat` type (optionally scoped) on the subject → minor.
|
|
1028
|
+
if (/^feat(\([^)]*\))?:/i.test(subject)) {
|
|
1029
|
+
return 'minor';
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
// Any other commit counts as patch.
|
|
1033
|
+
return 'patch';
|
|
1034
|
+
}
|
|
1035
|
+
|
|
764
1036
|
export async function evictGitLocks(workspacePath: string) {
|
|
765
1037
|
const gitDir = path.join(workspacePath, '.git');
|
|
766
1038
|
const lockFiles = await Fs.getFilePathsMatchingGlob(gitDir, '**/*.lock');
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { classifyCommitMessage } from '../src/versionWorkspace';
|
|
2
|
+
|
|
3
|
+
describe('classifyCommitMessage', () => {
|
|
4
|
+
describe('major (breaking-change)', () => {
|
|
5
|
+
it('classifies `feat!:` as major', () => {
|
|
6
|
+
expect(classifyCommitMessage('feat!: remove deprecated api')).toBe('major');
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it('classifies `fix!:` as major', () => {
|
|
10
|
+
expect(classifyCommitMessage('fix!: change default behavior')).toBe('major');
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('classifies scoped `feat(scope)!:` as major', () => {
|
|
14
|
+
expect(classifyCommitMessage('feat(auth)!: replace cookie format')).toBe('major');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('classifies `BREAKING CHANGE:` footer as major', () => {
|
|
18
|
+
expect(
|
|
19
|
+
classifyCommitMessage(
|
|
20
|
+
'feat: reorganize tool outputs\n\nDetails here.\n\nBREAKING CHANGE: tool result shape changed'
|
|
21
|
+
)
|
|
22
|
+
).toBe('major');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('classifies `BREAKING-CHANGE:` (hyphenated) as major', () => {
|
|
26
|
+
expect(classifyCommitMessage('refactor: blah\n\nBREAKING-CHANGE: something')).toBe('major');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('classifies `BREAKING CHANGE:` in subject as major', () => {
|
|
30
|
+
expect(classifyCommitMessage('feat: BREAKING CHANGE: removed old flow')).toBe('major');
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe('minor (feat)', () => {
|
|
35
|
+
it('classifies `feat:` as minor', () => {
|
|
36
|
+
expect(classifyCommitMessage('feat: add streaming support')).toBe('minor');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('classifies scoped `feat(scope):` as minor', () => {
|
|
40
|
+
expect(classifyCommitMessage('feat(ui): new button')).toBe('minor');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('is case-insensitive for type', () => {
|
|
44
|
+
expect(classifyCommitMessage('FEAT: uppercase type')).toBe('minor');
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe('patch (everything else)', () => {
|
|
49
|
+
it('classifies `fix:` as patch', () => {
|
|
50
|
+
expect(classifyCommitMessage('fix: off-by-one')).toBe('patch');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('classifies scoped `fix(scope):` as patch', () => {
|
|
54
|
+
expect(classifyCommitMessage('fix(parser): handle empty input')).toBe('patch');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('classifies `chore:` as patch', () => {
|
|
58
|
+
expect(classifyCommitMessage('chore: bump deps')).toBe('patch');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('classifies `docs:` as patch', () => {
|
|
62
|
+
expect(classifyCommitMessage('docs: update README')).toBe('patch');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('classifies `refactor:` as patch', () => {
|
|
66
|
+
expect(classifyCommitMessage('refactor: extract helper')).toBe('patch');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('classifies non-conventional messages as patch', () => {
|
|
70
|
+
expect(classifyCommitMessage('some random commit')).toBe('patch');
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe('edge cases', () => {
|
|
75
|
+
it('returns undefined for an empty message', () => {
|
|
76
|
+
expect(classifyCommitMessage('')).toBeUndefined();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('returns undefined for a whitespace-only message', () => {
|
|
80
|
+
expect(classifyCommitMessage(' \n\n ')).toBeUndefined();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('does not treat `feature:` as a feat', () => {
|
|
84
|
+
// Only exact `feat` type should promote to minor; `feature` is not a
|
|
85
|
+
// Conventional Commits type and falls through to patch.
|
|
86
|
+
expect(classifyCommitMessage('feature: something')).toBe('patch');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('does not treat a mid-sentence "BREAKING CHANGE" without the colon as major', () => {
|
|
90
|
+
// The colon is the conventional-commits signal; without it, this is
|
|
91
|
+
// just prose in the body.
|
|
92
|
+
expect(classifyCommitMessage('feat: this is NOT a BREAKING CHANGE really')).toBe('minor');
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
});
|
package/LICENSE
DELETED
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
MIT License
|
|
2
|
-
|
|
3
|
-
Copyright (c) 2023 Brent Bahry
|
|
4
|
-
|
|
5
|
-
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
-
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
-
in the Software without restriction, including without limitation the rights
|
|
8
|
-
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
-
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
-
furnished to do so, subject to the following conditions:
|
|
11
|
-
|
|
12
|
-
The above copyright notice and this permission notice shall be included in all
|
|
13
|
-
copies or substantial portions of the Software.
|
|
14
|
-
|
|
15
|
-
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
-
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
-
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
-
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
-
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
-
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
-
SOFTWARE.
|