@proteinjs/build 2.1.0 → 2.3.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,229 @@
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
+ /**
169
+ * Idempotency guard for the release flow. The versioning loop writes a package's bumped
170
+ * package.json to disk (versionWorkspace.ts) BEFORE it commits + pushes that package; an
171
+ * interruption in that window (a flaky release-build/test failure, a publish/push error) leaves an
172
+ * uncommitted bumped package.json. A blind re-run would then read the already-bumped DISK version
173
+ * as its base and double-bump it — or, for a partially-pushed multi-package repo, fail to re-flag
174
+ * the un-pushed package and silently SKIP it. Both produce a wrong release.
175
+ *
176
+ * So, before the versioning loop (release mode only), STOP if any leaf repo carries uncommitted
177
+ * package.json / package-lock.json — those can only be leftovers from a prior interrupted run
178
+ * (a clean merge phase commits its resolutions). Non-destructive: it names the files and the
179
+ * one-line reset, and never mutates — the operator resets to committed (source-of-truth) state and
180
+ * re-runs. On a fresh run after clean merges there is nothing uncommitted, so this is a no-op.
181
+ */
182
+ export async function assertNoLeftoverVersionState(workspacePath: string): Promise<void> {
183
+ const repoRoots = await leafRepoRoots(workspacePath);
184
+ const dirty: string[] = [];
185
+ for (const repoRoot of repoRoots) {
186
+ const out = await git(repoRoot, 'status --porcelain').catch(() => '');
187
+ for (const line of out
188
+ .split('\n')
189
+ .map((l) => l.trim())
190
+ .filter(Boolean)) {
191
+ const file = line.replace(/^.. /, '');
192
+ const base = path.basename(file);
193
+ if (base === 'package.json' || base === 'package-lock.json') {
194
+ dirty.push(`${path.basename(repoRoot)}: ${line}`);
195
+ }
196
+ }
197
+ }
198
+ if (dirty.length > 0) {
199
+ throw new Error(
200
+ `Refusing to version: uncommitted package.json/package-lock.json detected — almost certainly ` +
201
+ `leftovers from a PRIOR INTERRUPTED release run. Re-running blindly could double-bump or skip ` +
202
+ `these packages. Reset each to its committed (source-of-truth) state and re-run:\n` +
203
+ dirty.map((d) => ` ${d}`).join('\n') +
204
+ `\n (per repo: git checkout -- <file>, or git checkout -- . after confirming there is no ` +
205
+ `unrelated work)`
206
+ );
207
+ }
208
+ }
209
+
210
+ /** Unique git-repo roots that directly contain workspace packages — never the workspace root. */
211
+ async function leafRepoRoots(workspacePath: string): Promise<string[]> {
212
+ const { packageMap, sortedPackageNames } = await PackageUtil.getWorkspaceMetadata(workspacePath);
213
+ const roots = new Set<string>();
214
+ for (const packageName of sortedPackageNames) {
215
+ const localPackage = packageMap[packageName];
216
+ const packageDir = path.dirname(localPackage.filePath);
217
+ const repoRoot = await git(packageDir, 'rev-parse --show-toplevel').catch(() => undefined);
218
+ if (repoRoot && path.resolve(repoRoot) !== path.resolve(workspacePath)) {
219
+ roots.add(repoRoot);
220
+ }
221
+ }
222
+ return Array.from(roots).sort();
223
+ }
224
+
225
+ function git(cwd: string, args: string): Promise<string> {
226
+ return new Promise((resolve, reject) => {
227
+ exec(`git ${args}`, { cwd }, (error, stdout) => (error ? reject(error) : resolve(stdout.trim())));
228
+ });
229
+ }
@@ -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, assertNoLeftoverVersionState } 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,20 @@ 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
+
36
+ // Release-flow idempotency guard: after clean merges, uncommitted package.json/lock can only be
37
+ // leftovers from a prior interrupted versioning run — stop before the loop reads bad disk state.
38
+ // Release mode = --merge-to-main; skip in preview/dry modes (which legitimately leave writes).
39
+ if (mergeSpec.enabled && !planOnly && !dryRun) {
40
+ await assertNoLeftoverVersionState(workspacePath);
41
+ }
42
+
28
43
  const workspaceRootDirty = await isRepoDirty(workspacePath);
29
44
  if (workspaceRootDirty) {
30
45
  logger.info({ message: `> Workspace root is dirty, will skip pull/push for root repo` });
@@ -113,11 +128,7 @@ export async function versionWorkspace() {
113
128
  // In-memory version mutation runs in plan-only too so downstream
114
129
  // packages see the right dep versions when we simulate cascades.
115
130
  localPackage.packageJson.version = semver.inc(currentVersion, effectiveBump);
116
- const sourceNote = ownBump
117
- ? cascadeBump
118
- ? `own+cascade, own=${ownBump}`
119
- : `own=${ownBump}`
120
- : 'dep cascade';
131
+ const sourceNote = ownBump ? (cascadeBump ? `own+cascade, own=${ownBump}` : `own=${ownBump}`) : 'dep cascade';
121
132
  const planPrefix = planOnly ? 'would bump' : 'bumping';
122
133
  logger.info({
123
134
  message: `(${cw.color(packageName)}) ${planPrefix} version (${effectiveBump}; ${sourceNote}) from ${currentVersion} -> ${localPackage.packageJson.version}`,
@@ -392,10 +403,7 @@ async function applyDependencyVersionRewrites(
392
403
  * This is the input to `computeCommitLeaves` and to the per-package bump-level
393
404
  * combine in the main loop.
394
405
  */
395
- async function scanCommitBumps(
396
- packageNames: string[],
397
- packageMap: LocalPackageMap
398
- ): Promise<Map<string, CommitBump>> {
406
+ async function scanCommitBumps(packageNames: string[], packageMap: LocalPackageMap): Promise<Map<string, CommitBump>> {
399
407
  const result = new Map<string, CommitBump>();
400
408
  for (const packageName of packageNames) {
401
409
  const localPackage = packageMap[packageName];
@@ -911,7 +919,13 @@ async function publish(localPackage: LocalPackage) {
911
919
  }
912
920
 
913
921
  await retryOnNetworkError(
914
- () => cmd('npm', args, { cwd: packageDir, env: { ...process.env } }, { logPrefix: `[${cw.color(localPackage.name)}] ` }),
922
+ () =>
923
+ cmd(
924
+ 'npm',
925
+ args,
926
+ { cwd: packageDir, env: { ...process.env } },
927
+ { logPrefix: `[${cw.color(localPackage.name)}] ` }
928
+ ),
915
929
  localPackage.name,
916
930
  3,
917
931
  15_000
@@ -1057,7 +1071,10 @@ export async function classifyUnpushedCommits(dir: string): Promise<CommitBump |
1057
1071
  resolve(undefined);
1058
1072
  return;
1059
1073
  }
1060
- const commits = stdout.split('\x00').map((s) => s.trim()).filter((s) => s.length > 0);
1074
+ const commits = stdout
1075
+ .split('\x00')
1076
+ .map((s) => s.trim())
1077
+ .filter((s) => s.length > 0);
1061
1078
  let result: CommitBump | undefined;
1062
1079
  for (const commit of commits) {
1063
1080
  result = maxBump(result, classifyCommitMessage(commit));
@@ -1114,8 +1131,12 @@ export async function evictGitLocks(workspacePath: string) {
1114
1131
  function isNetworkError(error: any): boolean {
1115
1132
  const output = `${error.stdout ?? ''}${error.stderr ?? ''}`;
1116
1133
  return (
1117
- /ECONNRESET/i.test(output) || /ETIMEDOUT/i.test(output) || /ENOTFOUND/i.test(output) ||
1118
- /EAI_AGAIN/i.test(output) || /ECONNREFUSED/i.test(output) || /socket hang up/i.test(output) ||
1134
+ /ECONNRESET/i.test(output) ||
1135
+ /ETIMEDOUT/i.test(output) ||
1136
+ /ENOTFOUND/i.test(output) ||
1137
+ /EAI_AGAIN/i.test(output) ||
1138
+ /ECONNREFUSED/i.test(output) ||
1139
+ /socket hang up/i.test(output) ||
1119
1140
  /network/i.test(output)
1120
1141
  );
1121
1142
  }
@@ -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
+ });