@proteinjs/build 2.0.2 → 2.2.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.
@@ -0,0 +1,187 @@
1
+ import * as path from 'path';
2
+ import { exec } from 'child_process';
3
+ import { PackageUtil, cmd, LogColorWrapper } from '@proteinjs/util-node';
4
+ import { Logger } from '@proteinjs/logger';
5
+ import { primaryLogColor, secondaryLogColor } from './logColors';
6
+
7
+ const cw = new LogColorWrapper();
8
+ const logger = new Logger({ name: cw.color('workspace:', primaryLogColor) + cw.color('merge', secondaryLogColor) });
9
+
10
+ /**
11
+ * Opt-in pre-phase for `version-workspace`: merge feature-branch work into `main` in each leaf git
12
+ * repo BEFORE versioning, so the release runs on main. Without the flag, behavior is unchanged —
13
+ * the workspace versions in place on whatever branch each repo is on (the "stay on feature
14
+ * branches and just bump deps" mode).
15
+ *
16
+ * version-workspace --merge-to-main
17
+ * Every leaf repo whose current branch != main merges its current branch HEAD into main.
18
+ *
19
+ * version-workspace --merge-to-main=chat:17dda73,thought:ffdc105
20
+ * Only the named repos (matched by git-repo directory name), each merged at the PINNED sha —
21
+ * work pushed to the feature branch after the pin simply isn't in this release. A bare name
22
+ * (no `:sha`) merges that repo's current branch HEAD.
23
+ *
24
+ * Also accepted via env: VERSION_WORKSPACE_MERGE_TO_MAIN (same syntax; `1`/`true` = bare mode).
25
+ *
26
+ * Semantics per repo: fetch; checkout main; sync with origin/main FAST-FORWARD-ONLY (never rebase
27
+ * — local main legitimately holds an unpushed merge commit on resume, and a rebase would
28
+ * linearize it; true local/origin divergence stops loudly); merge the sha with a merge commit
29
+ * (`--no-ff`). Already-merged shas are skipped (idempotent — safe to re-run after fixing a
30
+ * conflict). A conflict stops the run with the repo + conflicted files named, leaving the merge in
31
+ * progress in that repo for resolution; re-running continues past it. Repos are LEFT ON MAIN
32
+ * afterward (the feature branch itself is never modified); the workspace root/metarepos are never
33
+ * touched by this phase.
34
+ */
35
+ export type MergeToMainSpec = { enabled: boolean; pins: Map<string, string | undefined> };
36
+
37
+ export function parseMergeToMainSpec(args: string[], envValue?: string): MergeToMainSpec {
38
+ const pins = new Map<string, string | undefined>();
39
+ let enabled = false;
40
+
41
+ const consume = (value: string) => {
42
+ enabled = true;
43
+ if (value === '' || value === 'true' || value === '1') {
44
+ return; // bare mode: all repos at branch HEAD
45
+ }
46
+ for (const entry of value.split(',')) {
47
+ const trimmed = entry.trim();
48
+ if (!trimmed) {
49
+ continue;
50
+ }
51
+ const [repo, sha] = trimmed.split(':').map((s) => s.trim());
52
+ pins.set(repo, sha || undefined);
53
+ }
54
+ };
55
+
56
+ for (const arg of args) {
57
+ if (arg === '--merge-to-main') {
58
+ consume('');
59
+ } else {
60
+ const match = arg.match(/^--merge-to-main=(.*)$/);
61
+ if (match) {
62
+ consume(match[1]);
63
+ }
64
+ }
65
+ }
66
+ if (!enabled && envValue) {
67
+ consume(envValue);
68
+ }
69
+
70
+ return { enabled, pins };
71
+ }
72
+
73
+ export async function mergeToMain(workspacePath: string, spec: MergeToMainSpec, planOnly: boolean): Promise<void> {
74
+ if (!spec.enabled) {
75
+ return;
76
+ }
77
+
78
+ const repoRoots = await leafRepoRoots(workspacePath);
79
+ for (const repoRoot of repoRoots) {
80
+ const repoName = path.basename(repoRoot);
81
+ const pinned = spec.pins.size > 0;
82
+ if (pinned && !spec.pins.has(repoName)) {
83
+ continue;
84
+ }
85
+ const branch = await git(repoRoot, 'rev-parse --abbrev-ref HEAD');
86
+ const sha = spec.pins.get(repoName) ?? (branch === 'main' ? undefined : await git(repoRoot, 'rev-parse HEAD'));
87
+ if (!sha) {
88
+ // Bare mode and already on main with no pin: nothing to merge.
89
+ continue;
90
+ }
91
+
92
+ if (planOnly) {
93
+ logger.info({
94
+ message: `(${cw.color(repoName)}) would merge ${sha.slice(0, 9)} (${branch}) into main. NOTE: plan-only bump levels below reflect PRE-merge state; run with --dry-run after merging for post-merge planning.`,
95
+ });
96
+ continue;
97
+ }
98
+
99
+ await cmd('git', ['fetch', '-q'], { cwd: repoRoot }, { logPrefix: `[${cw.color(repoName)}] ` });
100
+ try {
101
+ await git(repoRoot, `cat-file -e ${sha}^{commit}`);
102
+ } catch {
103
+ throw new Error(`(${repoName}) --merge-to-main sha ${sha} is not a commit in this repo`);
104
+ }
105
+
106
+ if (branch !== 'main') {
107
+ await cmd('git', ['checkout', 'main'], { cwd: repoRoot }, { logPrefix: `[${cw.color(repoName)}] ` });
108
+ }
109
+ // Sync local main with origin FAST-FORWARD-ONLY — never rebase: after a prior run of this
110
+ // phase, local main legitimately holds an UNPUSHED MERGE COMMIT, and a rebase would linearize
111
+ // it (replaying every branch commit, usually into conflicts). Ahead-of-origin is the normal
112
+ // resume state and is fine; true divergence (local merge AND origin moved) is abnormal —
113
+ // stop loudly rather than guess.
114
+ try {
115
+ await cmd(
116
+ 'git',
117
+ ['merge', '--ff-only', 'origin/main'],
118
+ { cwd: repoRoot },
119
+ { logPrefix: `[${cw.color(repoName)}] ` }
120
+ );
121
+ } catch {
122
+ const originIsAncestor = await git(repoRoot, 'merge-base --is-ancestor origin/main HEAD')
123
+ .then(() => true)
124
+ .catch(() => false);
125
+ if (!originIsAncestor) {
126
+ throw new Error(
127
+ `(${repoName}) local main and origin/main have DIVERGED (local holds unpushed commits AND origin moved). ` +
128
+ `Reconcile manually (e.g. merge origin/main into main), then re-run.`
129
+ );
130
+ }
131
+ logger.info({
132
+ message: `(${cw.color(repoName)}) local main is ahead of origin (unpushed merge from a prior run) — continuing`,
133
+ });
134
+ }
135
+
136
+ const alreadyMerged = await git(repoRoot, `merge-base --is-ancestor ${sha} HEAD`)
137
+ .then(() => true)
138
+ .catch(() => false);
139
+ if (alreadyMerged) {
140
+ logger.info({ message: `(${cw.color(repoName)}) ${sha.slice(0, 9)} already on main — skipping merge` });
141
+ continue;
142
+ }
143
+
144
+ logger.info({ message: `(${cw.color(repoName)}) merging ${sha.slice(0, 9)} (${branch}) into main` });
145
+ try {
146
+ await cmd(
147
+ 'git',
148
+ [
149
+ 'merge',
150
+ '--no-ff',
151
+ '-m',
152
+ `merge: ${branch} @ ${sha.slice(0, 9)} -> main (version-workspace --merge-to-main)`,
153
+ sha,
154
+ ],
155
+ { cwd: repoRoot },
156
+ { logPrefix: `[${cw.color(repoName)}] ` }
157
+ );
158
+ } catch (error) {
159
+ const conflicted = await git(repoRoot, 'diff --name-only --diff-filter=U').catch(() => '');
160
+ throw new Error(
161
+ `(${repoName}) merge of ${sha.slice(0, 9)} into main has conflicts: [${conflicted.split('\n').filter(Boolean).join(', ')}]. ` +
162
+ `Resolve + commit the merge in ${repoRoot}, then re-run — already-merged repos are skipped.`
163
+ );
164
+ }
165
+ }
166
+ }
167
+
168
+ /** Unique git-repo roots that directly contain workspace packages — never the workspace root. */
169
+ async function leafRepoRoots(workspacePath: string): Promise<string[]> {
170
+ const { packageMap, sortedPackageNames } = await PackageUtil.getWorkspaceMetadata(workspacePath);
171
+ const roots = new Set<string>();
172
+ for (const packageName of sortedPackageNames) {
173
+ const localPackage = packageMap[packageName];
174
+ const packageDir = path.dirname(localPackage.filePath);
175
+ const repoRoot = await git(packageDir, 'rev-parse --show-toplevel').catch(() => undefined);
176
+ if (repoRoot && path.resolve(repoRoot) !== path.resolve(workspacePath)) {
177
+ roots.add(repoRoot);
178
+ }
179
+ }
180
+ return Array.from(roots).sort();
181
+ }
182
+
183
+ function git(cwd: string, args: string): Promise<string> {
184
+ return new Promise((resolve, reject) => {
185
+ exec(`git ${args}`, { cwd }, (error, stdout) => (error ? reject(error) : resolve(stdout.trim())));
186
+ });
187
+ }
@@ -6,6 +6,7 @@ import semver from 'semver';
6
6
  import { Commit } from './Github';
7
7
  import { primaryLogColor, secondaryLogColor } from './logColors';
8
8
  import { hasLintConfig } from './lintWorkspace';
9
+ import { mergeToMain, parseMergeToMainSpec } from './mergeToMain';
9
10
 
10
11
  const cw = new LogColorWrapper();
11
12
  const logger = new Logger({ name: cw.color('workspace:', primaryLogColor) + cw.color('version', secondaryLogColor) });
@@ -25,6 +26,13 @@ export async function versionWorkspace() {
25
26
  }
26
27
  const workspacePath = process.cwd();
27
28
  await evictGitLocks(workspacePath);
29
+
30
+ // Opt-in pre-phase: merge feature-branch work into main per leaf repo before versioning (see
31
+ // mergeToMain.ts). Default (no flag) is unchanged: version in place on each repo's current
32
+ // branch. Repos this phase touches are left ON MAIN; feature branches are never modified.
33
+ const mergeSpec = parseMergeToMainSpec(process.argv.slice(2), process.env.VERSION_WORKSPACE_MERGE_TO_MAIN);
34
+ await mergeToMain(workspacePath, mergeSpec, planOnly);
35
+
28
36
  const workspaceRootDirty = await isRepoDirty(workspacePath);
29
37
  if (workspaceRootDirty) {
30
38
  logger.info({ message: `> Workspace root is dirty, will skip pull/push for root repo` });
@@ -37,7 +45,17 @@ export async function versionWorkspace() {
37
45
 
38
46
  const { packageMap, packageGraph, sortedPackageNames, workspaceToPackageMap } =
39
47
  await PackageUtil.getWorkspaceMetadata(workspacePath);
40
- const skippedPackages = ['root', 'typescript-parser'];
48
+ const userSkippedPackages = getUserSkippedPackages();
49
+ if (userSkippedPackages.size > 0) {
50
+ logger.info({
51
+ message: `> Skipping packages this run (no version/build/publish; dependents keep their current dep versions): ${Array.from(
52
+ userSkippedPackages
53
+ )
54
+ .map((n) => cw.color(n))
55
+ .join(', ')}`,
56
+ });
57
+ }
58
+ const skippedPackages = ['root', 'typescript-parser', ...Array.from(userSkippedPackages)];
41
59
  const filteredPackageNames = sortedPackageNames.filter((packageName) => {
42
60
  const localPackage = packageMap[packageName];
43
61
  return (
@@ -85,7 +103,12 @@ export async function versionWorkspace() {
85
103
  const localPackage = packageMap[packageName];
86
104
  const skipBumpingPackageVersion = isInFixedVersionWorkspace(localPackage);
87
105
  const ownBump = skipBumpingPackageVersion ? undefined : commitBumps.get(packageName);
88
- const dependenciesChanged = await applyDependencyVersionRewrites(localPackage, packageMap, packageGraph);
106
+ const dependenciesChanged = await applyDependencyVersionRewrites(
107
+ localPackage,
108
+ packageMap,
109
+ packageGraph,
110
+ userSkippedPackages
111
+ );
89
112
  const cascadeBump: CommitBump | undefined = dependenciesChanged ? 'patch' : undefined;
90
113
  const effectiveBump = maxBump(ownBump, cascadeBump);
91
114
 
@@ -98,11 +121,7 @@ export async function versionWorkspace() {
98
121
  // In-memory version mutation runs in plan-only too so downstream
99
122
  // packages see the right dep versions when we simulate cascades.
100
123
  localPackage.packageJson.version = semver.inc(currentVersion, effectiveBump);
101
- const sourceNote = ownBump
102
- ? cascadeBump
103
- ? `own+cascade, own=${ownBump}`
104
- : `own=${ownBump}`
105
- : 'dep cascade';
124
+ const sourceNote = ownBump ? (cascadeBump ? `own+cascade, own=${ownBump}` : `own=${ownBump}`) : 'dep cascade';
106
125
  const planPrefix = planOnly ? 'would bump' : 'bumping';
107
126
  logger.info({
108
127
  message: `(${cw.color(packageName)}) ${planPrefix} version (${effectiveBump}; ${sourceNote}) from ${currentVersion} -> ${localPackage.packageJson.version}`,
@@ -208,6 +227,46 @@ function isPlanOnly() {
208
227
  return false;
209
228
  }
210
229
 
230
+ /**
231
+ * Packages to exclude from this run entirely: not versioned, built, or
232
+ * published, and — critically — dependents' references to them are NOT
233
+ * rewritten to the on-disk version (see `applyDependencyVersionRewrites`).
234
+ *
235
+ * Use case: a package in the workspace has shipped a breaking new major
236
+ * (already published and checked out locally) but its consumers haven't been
237
+ * migrated yet. Skipping it lets the rest of the workspace version and deploy
238
+ * while consumers stay pinned to the version they were built against.
239
+ *
240
+ * Accepts `--skip=<name>[,<name>...]` (repeatable) or the
241
+ * VERSION_WORKSPACE_SKIP env var, e.g.:
242
+ * version-workspace --skip=@proteinjs/conversation
243
+ */
244
+ function getUserSkippedPackages(): Set<string> {
245
+ const skipped = new Set<string>();
246
+ const args = process.argv.slice(2);
247
+ for (const arg of args) {
248
+ const match = arg.match(/^--skip=(.+)$/);
249
+ if (match) {
250
+ match[1]
251
+ .split(',')
252
+ .map((name) => name.trim())
253
+ .filter((name) => name.length > 0)
254
+ .forEach((name) => skipped.add(name));
255
+ }
256
+ }
257
+
258
+ const envFlag = process.env.VERSION_WORKSPACE_SKIP;
259
+ if (envFlag) {
260
+ envFlag
261
+ .split(',')
262
+ .map((name) => name.trim())
263
+ .filter((name) => name.length > 0)
264
+ .forEach((name) => skipped.add(name));
265
+ }
266
+
267
+ return skipped;
268
+ }
269
+
211
270
  function isInFixedVersionWorkspace(localPackage: LocalPackage) {
212
271
  return (
213
272
  localPackage.workspace &&
@@ -280,7 +339,8 @@ async function pullWorkspace(workspacePath: string, skipRootRepo = false) {
280
339
  async function applyDependencyVersionRewrites(
281
340
  localPackage: LocalPackage,
282
341
  packageMap: LocalPackageMap,
283
- packageGraph: any
342
+ packageGraph: any,
343
+ userSkippedPackages: Set<string> = new Set()
284
344
  ): Promise<boolean> {
285
345
  const localDependencies = packageGraph.successors(localPackage.name);
286
346
  if (!localDependencies || localDependencies.length == 0) {
@@ -306,6 +366,16 @@ async function applyDependencyVersionRewrites(
306
366
  continue;
307
367
  }
308
368
 
369
+ if (userSkippedPackages.has(localDependency)) {
370
+ // The dep was skipped via --skip: leave this package's reference at its
371
+ // current (published, known-compatible) version instead of rewriting to
372
+ // the on-disk version of the skipped package.
373
+ logger.info({
374
+ message: `(${cw.color(localPackage.name)}) keeping dependency version of ${cw.color(localDependency)} at ${currentDependencyVersion.prefix ?? ''}${currentDependencyVersion.version} (skipped via --skip; on-disk is ${localDependencyVersion})`,
375
+ });
376
+ continue;
377
+ }
378
+
309
379
  const newDependencyVersion: DependencyVersion = {
310
380
  prefix: currentDependencyVersion.prefix,
311
381
  version: localDependencyVersion,
@@ -326,10 +396,7 @@ async function applyDependencyVersionRewrites(
326
396
  * This is the input to `computeCommitLeaves` and to the per-package bump-level
327
397
  * combine in the main loop.
328
398
  */
329
- async function scanCommitBumps(
330
- packageNames: string[],
331
- packageMap: LocalPackageMap
332
- ): Promise<Map<string, CommitBump>> {
399
+ async function scanCommitBumps(packageNames: string[], packageMap: LocalPackageMap): Promise<Map<string, CommitBump>> {
333
400
  const result = new Map<string, CommitBump>();
334
401
  for (const packageName of packageNames) {
335
402
  const localPackage = packageMap[packageName];
@@ -845,7 +912,13 @@ async function publish(localPackage: LocalPackage) {
845
912
  }
846
913
 
847
914
  await retryOnNetworkError(
848
- () => cmd('npm', args, { cwd: packageDir, env: { ...process.env } }, { logPrefix: `[${cw.color(localPackage.name)}] ` }),
915
+ () =>
916
+ cmd(
917
+ 'npm',
918
+ args,
919
+ { cwd: packageDir, env: { ...process.env } },
920
+ { logPrefix: `[${cw.color(localPackage.name)}] ` }
921
+ ),
849
922
  localPackage.name,
850
923
  3,
851
924
  15_000
@@ -991,7 +1064,10 @@ export async function classifyUnpushedCommits(dir: string): Promise<CommitBump |
991
1064
  resolve(undefined);
992
1065
  return;
993
1066
  }
994
- const commits = stdout.split('\x00').map((s) => s.trim()).filter((s) => s.length > 0);
1067
+ const commits = stdout
1068
+ .split('\x00')
1069
+ .map((s) => s.trim())
1070
+ .filter((s) => s.length > 0);
995
1071
  let result: CommitBump | undefined;
996
1072
  for (const commit of commits) {
997
1073
  result = maxBump(result, classifyCommitMessage(commit));
@@ -1048,8 +1124,12 @@ export async function evictGitLocks(workspacePath: string) {
1048
1124
  function isNetworkError(error: any): boolean {
1049
1125
  const output = `${error.stdout ?? ''}${error.stderr ?? ''}`;
1050
1126
  return (
1051
- /ECONNRESET/i.test(output) || /ETIMEDOUT/i.test(output) || /ENOTFOUND/i.test(output) ||
1052
- /EAI_AGAIN/i.test(output) || /ECONNREFUSED/i.test(output) || /socket hang up/i.test(output) ||
1127
+ /ECONNRESET/i.test(output) ||
1128
+ /ETIMEDOUT/i.test(output) ||
1129
+ /ENOTFOUND/i.test(output) ||
1130
+ /EAI_AGAIN/i.test(output) ||
1131
+ /ECONNREFUSED/i.test(output) ||
1132
+ /socket hang up/i.test(output) ||
1053
1133
  /network/i.test(output)
1054
1134
  );
1055
1135
  }
@@ -0,0 +1,43 @@
1
+ import { parseMergeToMainSpec } from '../src/mergeToMain';
2
+
3
+ describe('parseMergeToMainSpec', () => {
4
+ it('is disabled with no flag and no env', () => {
5
+ expect(parseMergeToMainSpec([]).enabled).toBe(false);
6
+ expect(parseMergeToMainSpec(['--dry-run']).enabled).toBe(false);
7
+ });
8
+
9
+ it('bare flag enables merge for all repos at branch HEAD', () => {
10
+ const spec = parseMergeToMainSpec(['--merge-to-main']);
11
+ expect(spec.enabled).toBe(true);
12
+ expect(spec.pins.size).toBe(0);
13
+ });
14
+
15
+ it('pinned form names repos with shas', () => {
16
+ const spec = parseMergeToMainSpec(['--merge-to-main=chat:17dda73,thought:ffdc105']);
17
+ expect(spec.enabled).toBe(true);
18
+ expect(spec.pins.get('chat')).toBe('17dda73');
19
+ expect(spec.pins.get('thought')).toBe('ffdc105');
20
+ expect(spec.pins.has('flow')).toBe(false);
21
+ });
22
+
23
+ it('bare repo name (no sha) pins the repo at its branch HEAD', () => {
24
+ const spec = parseMergeToMainSpec(['--merge-to-main=chat,thought:abc123']);
25
+ expect(spec.pins.has('chat')).toBe(true);
26
+ expect(spec.pins.get('chat')).toBeUndefined();
27
+ expect(spec.pins.get('thought')).toBe('abc123');
28
+ });
29
+
30
+ it('repeatable flags accumulate pins', () => {
31
+ const spec = parseMergeToMainSpec(['--merge-to-main=chat:a', '--merge-to-main=flow:b']);
32
+ expect(spec.pins.get('chat')).toBe('a');
33
+ expect(spec.pins.get('flow')).toBe('b');
34
+ });
35
+
36
+ it('env var enables when no flag is present; flag wins over env', () => {
37
+ expect(parseMergeToMainSpec([], '1').enabled).toBe(true);
38
+ expect(parseMergeToMainSpec([], 'chat:abc').pins.get('chat')).toBe('abc');
39
+ const flagWins = parseMergeToMainSpec(['--merge-to-main=flow:x'], 'chat:abc');
40
+ expect(flagWins.pins.has('chat')).toBe(false);
41
+ expect(flagWins.pins.get('flow')).toBe('x');
42
+ });
43
+ });