@fuzdev/fuz_gitops 0.72.0 → 0.73.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.
Files changed (110) hide show
  1. package/README.md +13 -6
  2. package/dist/changeset_generator.d.ts.map +1 -1
  3. package/dist/changeset_generator.js +2 -16
  4. package/dist/changeset_reader.d.ts +1 -1
  5. package/dist/changeset_reader.d.ts.map +1 -1
  6. package/dist/dependency_graph.js +1 -1
  7. package/dist/git_operations.d.ts +5 -0
  8. package/dist/git_operations.d.ts.map +1 -1
  9. package/dist/git_operations.js +5 -0
  10. package/dist/gitops_analyze.task.d.ts +1 -0
  11. package/dist/gitops_analyze.task.d.ts.map +1 -1
  12. package/dist/gitops_analyze.task.js +12 -17
  13. package/dist/gitops_plan.task.d.ts +1 -0
  14. package/dist/gitops_plan.task.d.ts.map +1 -1
  15. package/dist/gitops_plan.task.js +9 -2
  16. package/dist/gitops_publish.task.d.ts +2 -2
  17. package/dist/gitops_publish.task.d.ts.map +1 -1
  18. package/dist/gitops_publish.task.js +83 -28
  19. package/dist/gitops_run.task.d.ts +2 -1
  20. package/dist/gitops_run.task.d.ts.map +1 -1
  21. package/dist/gitops_run.task.js +40 -7
  22. package/dist/gitops_sync.task.d.ts +1 -0
  23. package/dist/gitops_sync.task.d.ts.map +1 -1
  24. package/dist/gitops_sync.task.js +16 -2
  25. package/dist/gitops_task_helpers.d.ts +16 -2
  26. package/dist/gitops_task_helpers.d.ts.map +1 -1
  27. package/dist/gitops_task_helpers.js +11 -3
  28. package/dist/gitops_validate.task.d.ts +1 -0
  29. package/dist/gitops_validate.task.d.ts.map +1 -1
  30. package/dist/gitops_validate.task.js +20 -20
  31. package/dist/graph_validation.d.ts +16 -1
  32. package/dist/graph_validation.d.ts.map +1 -1
  33. package/dist/graph_validation.js +15 -0
  34. package/dist/local_repo.d.ts +22 -10
  35. package/dist/local_repo.d.ts.map +1 -1
  36. package/dist/local_repo.js +92 -75
  37. package/dist/log_helpers.d.ts +0 -5
  38. package/dist/log_helpers.d.ts.map +1 -1
  39. package/dist/log_helpers.js +8 -19
  40. package/dist/multi_repo_publisher.d.ts +21 -2
  41. package/dist/multi_repo_publisher.d.ts.map +1 -1
  42. package/dist/multi_repo_publisher.js +279 -287
  43. package/dist/operations.d.ts +6 -26
  44. package/dist/operations.d.ts.map +1 -1
  45. package/dist/operations_defaults.d.ts.map +1 -1
  46. package/dist/operations_defaults.js +1 -19
  47. package/dist/output_helpers.d.ts.map +1 -1
  48. package/dist/output_helpers.js +7 -6
  49. package/dist/paths.d.ts +0 -4
  50. package/dist/paths.d.ts.map +1 -1
  51. package/dist/paths.js +0 -4
  52. package/dist/publish_gate.d.ts +44 -0
  53. package/dist/publish_gate.d.ts.map +1 -0
  54. package/dist/publish_gate.js +32 -0
  55. package/dist/publish_steps.d.ts +60 -0
  56. package/dist/publish_steps.d.ts.map +1 -0
  57. package/dist/publish_steps.js +113 -0
  58. package/dist/publishing_event.d.ts +12 -19
  59. package/dist/publishing_event.d.ts.map +1 -1
  60. package/dist/publishing_event.js +16 -25
  61. package/dist/publishing_plan.d.ts +1 -2
  62. package/dist/publishing_plan.d.ts.map +1 -1
  63. package/dist/publishing_plan.js +18 -1
  64. package/dist/publishing_plan_helpers.d.ts +1 -1
  65. package/dist/publishing_plan_helpers.d.ts.map +1 -1
  66. package/dist/publishing_plan_helpers.js +2 -15
  67. package/dist/publishing_plan_logging.d.ts.map +1 -1
  68. package/dist/publishing_plan_logging.js +15 -23
  69. package/dist/version_utils.d.ts +17 -3
  70. package/dist/version_utils.d.ts.map +1 -1
  71. package/dist/version_utils.js +22 -0
  72. package/package.json +5 -6
  73. package/src/lib/changeset_generator.ts +10 -19
  74. package/src/lib/changeset_reader.ts +1 -2
  75. package/src/lib/dependency_graph.ts +1 -1
  76. package/src/lib/git_operations.ts +5 -0
  77. package/src/lib/gitops_analyze.task.ts +18 -23
  78. package/src/lib/gitops_plan.task.ts +10 -2
  79. package/src/lib/gitops_publish.task.ts +92 -27
  80. package/src/lib/gitops_run.task.ts +39 -7
  81. package/src/lib/gitops_sync.task.ts +18 -3
  82. package/src/lib/gitops_task_helpers.ts +20 -3
  83. package/src/lib/gitops_validate.task.ts +30 -22
  84. package/src/lib/graph_validation.ts +26 -0
  85. package/src/lib/local_repo.ts +114 -83
  86. package/src/lib/log_helpers.ts +8 -26
  87. package/src/lib/multi_repo_publisher.ts +336 -353
  88. package/src/lib/operations.ts +6 -22
  89. package/src/lib/operations_defaults.ts +1 -19
  90. package/src/lib/output_helpers.ts +7 -6
  91. package/src/lib/paths.ts +0 -5
  92. package/src/lib/publish_gate.ts +53 -0
  93. package/src/lib/publish_steps.ts +149 -0
  94. package/src/lib/publishing_event.ts +16 -25
  95. package/src/lib/publishing_plan.ts +23 -3
  96. package/src/lib/publishing_plan_helpers.ts +5 -18
  97. package/src/lib/publishing_plan_logging.ts +19 -39
  98. package/src/lib/version_utils.ts +30 -9
  99. package/dist/npm_install_helpers.d.ts +0 -23
  100. package/dist/npm_install_helpers.d.ts.map +0 -1
  101. package/dist/npm_install_helpers.js +0 -60
  102. package/dist/semver.d.ts +0 -26
  103. package/dist/semver.d.ts.map +0 -1
  104. package/dist/semver.js +0 -137
  105. package/dist/serialization_types.d.ts +0 -59
  106. package/dist/serialization_types.d.ts.map +0 -1
  107. package/dist/serialization_types.js +0 -42
  108. package/src/lib/npm_install_helpers.ts +0 -85
  109. package/src/lib/semver.ts +0 -170
  110. package/src/lib/serialization_types.ts +0 -92
@@ -2,16 +2,36 @@ import { TaskError } from '@fuzdev/gro';
2
2
  import { join } from 'node:path';
3
3
  import { styleText as st } from 'node:util';
4
4
  import { update_package_json } from './dependency_updater.js';
5
- import { validate_dependency_graph } from './graph_validation.js';
6
- import { needs_update, is_breaking_change, detect_bump_type } from './version_utils.js';
5
+ import { generate_publishing_plan, } from './publishing_plan.js';
7
6
  import { default_gitops_operations } from './operations_defaults.js';
8
- import { GITOPS_MAX_ITERATIONS_DEFAULT, GITOPS_NPM_WAIT_TIMEOUT_DEFAULT, } from './gitops_constants.js';
9
- import { install_with_cache_healing } from './npm_install_helpers.js';
7
+ import { GITOPS_NPM_WAIT_TIMEOUT_DEFAULT } from './gitops_constants.js';
10
8
  import { summarize_events, } from './publishing_event.js';
11
9
  import { capture_handler, multi_handler, } from './publishing_event_handler.js';
12
10
  export const publish_repos = async (repos, options) => {
11
+ const { log, ops = default_gitops_operations } = options;
12
+ // Convenience entry: generate the plan, then execute it. Callers that already hold a
13
+ // plan (e.g. to display and confirm it first) call `execute_publishing_plan` directly,
14
+ // so the plan is generated exactly once per command.
15
+ const plan = await generate_publishing_plan(repos, { log, ops: ops.changeset });
16
+ return execute_publishing_plan(repos, plan, options);
17
+ };
18
+ /**
19
+ * Executes a frozen publishing plan in a single linear pass — the "dumb executor" half of
20
+ * the zap model. The plan is the single source of truth; this re-derives nothing.
21
+ *
22
+ * Fails loud when the plan couldn't be fully computed (`plan.errors`): a wetrun aborts
23
+ * before any side effect, a dry run still reports the partial cascade but returns
24
+ * `ok: false`. This is the single error gate — `--no-plan` can't bypass it.
25
+ */
26
+ export const execute_publishing_plan = async (repos, plan, options) => {
13
27
  const start_time = Date.now();
14
- const { wetrun, update_deps, log, ops = default_gitops_operations } = options;
28
+ const { wetrun, log, ops = default_gitops_operations } = options;
29
+ // Fail loud on an incomplete plan. Executing one with errors would silently skip the
30
+ // affected packages. A wetrun aborts before touching npm or git; a dry run proceeds
31
+ // (no side effects) but the result is not ok (see the summary below).
32
+ if (wetrun && plan.errors.length > 0) {
33
+ throw new TaskError(`Cannot publish — the plan has ${plan.errors.length} error(s):\n ${plan.errors.join('\n ')}`);
34
+ }
15
35
  // Capture every event for the result; also forward to the caller's sink if provided.
16
36
  const capture = capture_handler();
17
37
  const events_handler = options.events ? multi_handler([options.events, capture]) : capture;
@@ -40,262 +60,220 @@ export const publish_repos = async (repos, options) => {
40
60
  else {
41
61
  log?.info('⏭️ Skipping preflight checks (dry run)');
42
62
  }
43
- // Build dependency graph and validate
44
- const { publishing_order: order } = validate_dependency_graph(repos, {
45
- log,
46
- throw_on_prod_cycles: true,
47
- log_cycles: true,
48
- log_order: true,
49
- });
63
+ // The plan already validated the dependency graph and resolved the full cascade
64
+ // (explicit changesets, bump escalations, and auto-generated changesets) — execute its
65
+ // frozen topological order directly, re-deriving nothing.
66
+ const order = plan.publishing_order;
67
+ const plan_changes = new Map(plan.version_changes.map((vc) => [vc.package_name, vc]));
68
+ if (order.length > 0) {
69
+ log?.info(` Publishing order: ${order.join(' → ')}`);
70
+ }
50
71
  emit({ event: 'run_started', wetrun, total: order.length });
51
72
  const published = new Map();
52
73
  const failed = new Map();
53
74
  const changed_repos = new Set(); // Track repos with any changes for selective deployment
54
- const skipped_packages = new Set(); // dedupe the package_skipped event across iterations
55
- // Fixed-point iteration: keep publishing until no new changesets are created
56
- // This handles transitive dependency updates (auto-generated changesets)
57
- let iteration = 0;
58
- let converged = false;
59
- while (!converged && iteration < GITOPS_MAX_ITERATIONS_DEFAULT) {
60
- iteration++;
61
- emit({ event: 'iteration_started', iteration, max: GITOPS_MAX_ITERATIONS_DEFAULT });
62
- log?.info(st('cyan', `\n🚀 ${wetrun ? 'Publishing' : 'Dry run'} iteration ${iteration}/${GITOPS_MAX_ITERATIONS_DEFAULT}...\n`));
63
- // Track if any packages were published in this iteration
64
- let published_in_iteration = false;
65
- let published_count = 0;
66
- // Track repos changed in THIS iteration only (for batch install)
67
- const changed_in_iteration = new Set();
68
- // Phase 1: Publish each package and immediately update dependents
69
- for (let i = 0; i < order.length; i++) {
70
- const pkg_name = order[i];
71
- const repo = repos.find((r) => r.library.name === pkg_name);
72
- if (!repo)
73
- continue;
74
- // Skip if already published in a previous iteration
75
- if (published.has(pkg_name)) {
76
- continue;
77
- }
78
- // Check for changesets (both dry and real runs)
79
- const has_result = await ops.changeset.has_changesets({ repo });
80
- if (!has_result.ok) {
81
- // Failed to check changesets
82
- const err = new Error(`Failed to check changesets: ${has_result.message}`);
75
+ // Name → repo index, built once to avoid repeated linear scans of `repos`.
76
+ const repo_by_name = new Map(repos.map((r) => [r.library.name, r]));
77
+ log?.info(st('cyan', `\n🚀 ${wetrun ? 'Publishing' : 'Dry run'}...\n`));
78
+ // Phase 1: one linear pass over the plan's topological order. The plan already
79
+ // resolved the full cascade, and publishing a package immediately rewrites every
80
+ // dependent's package.json and creates its auto-changeset — so by the time the pass
81
+ // reaches any package, all its dependencies have published and its changeset exists.
82
+ // No fixed-point loop is needed: a single pass converges by construction.
83
+ for (let i = 0; i < order.length; i++) {
84
+ const pkg_name = order[i];
85
+ const planned = plan_changes.get(pkg_name);
86
+ // Not in the plan = no changesets and no dependency updates = nothing to publish.
87
+ if (!planned) {
88
+ emit({ event: 'package_skipped', name: pkg_name, reason: 'no changesets' });
89
+ log?.info(st('yellow', ` ⚠️ Skipping ${pkg_name} - no changesets`));
90
+ continue;
91
+ }
92
+ const repo = repo_by_name.get(pkg_name);
93
+ if (!repo)
94
+ continue;
95
+ // An earlier publish may have rewritten this package's dependency ranges. No install is
96
+ // needed here: `gro publish` runs its own install (which self-heals npm's stale-cache
97
+ // ETARGET), so the package's freshly-rewritten deps are installed and healed as part of
98
+ // publishing it.
99
+ try {
100
+ // 1. Publish this package (real publish or dry-run prediction)
101
+ log?.info(st('dim', ` [${i + 1}/${order.length}] ${wetrun ? 'Publishing' : 'Would publish'} ${pkg_name}...`));
102
+ const version = await publish_single_repo(repo, options, ops, planned);
103
+ // Fail loud on drift: a real publish that lands a version the plan didn't predict
104
+ // is an invariant violation, not a routine failure. Abort and leave the dirty
105
+ // state in place — re-running re-plans from the current state (the just-published
106
+ // package no longer has changesets, so it drops out of the new plan).
107
+ if (wetrun && version.new_version !== planned.to) {
108
+ const err = new Error(`Plan drift for ${pkg_name}: published ${version.new_version} but the plan predicted ${planned.to}. ` +
109
+ `Aborting — re-run 'gro gitops_publish --wetrun' to re-plan from the current state.`);
83
110
  failed.set(pkg_name, err);
84
- emit({ event: 'package_failed', name: pkg_name, error: err.message, code: 'dependency' });
111
+ emit({ event: 'package_failed', name: pkg_name, error: err.message, code: 'drift' });
85
112
  log?.error(st('red', ` ❌ ${err.message}`));
86
113
  break;
87
114
  }
88
- if (!has_result.value) {
89
- // Skip packages without changesets
90
- // In real publish: They might get auto-changesets during dependency updates
91
- // In dry run: We can't simulate auto-changesets, so just skip
92
- // Emit once per package — the loop revisits no-changeset packages each iteration
93
- if (!skipped_packages.has(pkg_name)) {
94
- skipped_packages.add(pkg_name);
95
- emit({ event: 'package_skipped', name: pkg_name, reason: 'no changesets' });
96
- }
97
- if (!wetrun) {
98
- // Silent skip in dry run - plan shows which packages get auto-changesets
99
- continue;
100
- }
101
- else {
102
- log?.info(st('yellow', ` ⚠️ Skipping ${pkg_name} - no changesets`));
103
- continue;
104
- }
105
- }
106
- try {
107
- // 1. Publish this package
108
- log?.info(st('dim', ` [${i + 1}/${order.length}] ${wetrun ? 'Publishing' : 'Would publish'} ${pkg_name}...`));
109
- const version = await publish_single_repo(repo, options, ops);
110
- published.set(pkg_name, version);
111
- changed_repos.add(pkg_name); // Mark as changed for deployment
112
- // Note: don't add to changed_in_iteration - published packages don't need install
113
- // (their dependencies didn't change, only their version)
114
- published_in_iteration = true;
115
- published_count++;
116
- emit({
117
- event: 'package_completed',
118
- name: pkg_name,
119
- old_version: version.old_version,
120
- new_version: version.new_version,
121
- bump_type: version.bump_type,
122
- breaking: version.breaking,
123
- commit: version.commit,
124
- tag: version.tag,
115
+ published.set(pkg_name, version);
116
+ changed_repos.add(pkg_name); // Mark as changed for deployment
117
+ emit({
118
+ event: 'package_completed',
119
+ name: pkg_name,
120
+ old_version: version.old_version,
121
+ new_version: version.new_version,
122
+ bump_type: version.bump_type,
123
+ breaking: version.breaking,
124
+ commit: version.commit,
125
+ tag: version.tag,
126
+ });
127
+ log?.info(wetrun
128
+ ? st('green', ` ✅ Published ${pkg_name}@${version.new_version}`)
129
+ : st('cyan', ` ◇ Would publish ${pkg_name}@${version.new_version}`));
130
+ if (wetrun) {
131
+ // 2. Wait for this package to be available on NPM
132
+ log?.info(` ⏳ Waiting for ${pkg_name}@${version.new_version} on NPM...`);
133
+ const wait_result = await ops.npm.wait_for_package({
134
+ pkg: pkg_name,
135
+ version: version.new_version,
136
+ wait_options: {
137
+ max_attempts: 30,
138
+ initial_delay: 1000,
139
+ max_delay: 60000,
140
+ timeout: options.max_wait ?? GITOPS_NPM_WAIT_TIMEOUT_DEFAULT,
141
+ },
142
+ log,
125
143
  });
126
- log?.info(wetrun
127
- ? st('green', ` ✅ Published ${pkg_name}@${version.new_version}`)
128
- : st('cyan', ` ◇ Would publish ${pkg_name}@${version.new_version}`));
129
- if (wetrun) {
130
- // 2. Wait for this package to be available on NPM
131
- log?.info(` ⏳ Waiting for ${pkg_name}@${version.new_version} on NPM...`);
132
- const wait_result = await ops.npm.wait_for_package({
133
- pkg: pkg_name,
134
- version: version.new_version,
135
- wait_options: {
136
- max_attempts: 30,
137
- initial_delay: 1000,
138
- max_delay: 60000,
139
- timeout: options.max_wait ?? GITOPS_NPM_WAIT_TIMEOUT_DEFAULT,
140
- },
141
- log,
142
- });
143
- if (!wait_result.ok) {
144
- // Handle inline (don't throw into the generic catch): the npm-wait failure
145
- // carries a typed `timeout` signal, so we know this is a network failure
146
- // without sniffing the message.
147
- const err = new Error(`Failed to wait for package: ${wait_result.message}${wait_result.timeout ? ' (timeout)' : ''}`);
148
- failed.set(pkg_name, err);
149
- emit({ event: 'package_failed', name: pkg_name, error: err.message, code: 'network' });
150
- log?.error(st('red', ` ❌ Failed to publish ${pkg_name}: ${err.message}`));
151
- break; // fail fast
144
+ if (!wait_result.ok) {
145
+ // Handle inline (don't throw into the generic catch): the npm-wait failure
146
+ // carries a typed `timeout` signal, so we know this is a network failure
147
+ // without sniffing the message.
148
+ const err = new Error(`Failed to wait for package: ${wait_result.message}${wait_result.timeout ? ' (timeout)' : ''}`);
149
+ failed.set(pkg_name, err);
150
+ emit({ event: 'package_failed', name: pkg_name, error: err.message, code: 'network' });
151
+ log?.error(st('red', ` ❌ Failed to publish ${pkg_name}: ${err.message}`));
152
+ break; // fail fast
153
+ }
154
+ emit({ event: 'npm_waited', name: pkg_name, version: version.new_version });
155
+ // 3. Update every dependent the plan says has a prod/peer dep on this package.
156
+ // This rewrites their package.json ranges and creates their auto-changeset,
157
+ // which a later step of this same pass publishes. The dependent is queued for
158
+ // a single install just before it publishes (see the top of the loop).
159
+ const dependent_updates = group_dependency_updates(plan.dependency_updates, published, (update) => update.updated_dependency === pkg_name &&
160
+ (update.type === 'dependencies' || update.type === 'peerDependencies'));
161
+ for (const [dependent_name, updates] of dependent_updates) {
162
+ const dependent_repo = repo_by_name.get(dependent_name);
163
+ if (!dependent_repo)
164
+ continue;
165
+ // A dependent republishes iff the plan gave it a version change. Private packages
166
+ // are excluded from the plan's version changes, so they take the update-only-leaf
167
+ // path: rewrite + commit their dependency ranges with NO changeset and NO
168
+ // publish/npm-wait. Publishable dependents get an auto-changeset and republish in
169
+ // turn — `gro publish` installs + heals their rewritten deps when it reaches them.
170
+ const republishes = plan_changes.has(dependent_name);
171
+ for (const [dep_name, dep_version] of updates) {
172
+ log?.info(` Updating ${dependent_name}'s dependency on ${dep_name}`);
173
+ emit({
174
+ event: 'dependency_updated',
175
+ dependent: dependent_name,
176
+ dependency: dep_name,
177
+ version: dep_version,
178
+ dep_type: dependency_update_type(plan, dependent_name, dep_name),
179
+ creates_changeset: republishes,
180
+ });
181
+ }
182
+ changed_repos.add(dependent_name); // Mark as changed for deployment
183
+ if (republishes) {
184
+ await update_package_json(dependent_repo, updates, {
185
+ strategy: options.version_strategy || 'caret',
186
+ published_versions: published, // creates the auto-changeset
187
+ log,
188
+ git_ops: ops.git,
189
+ fs_ops: ops.fs,
190
+ });
152
191
  }
153
- // 3. Update all repos that have prod/peer deps on this package
154
- if (update_deps) {
155
- for (const dependent_repo of repos) {
156
- const updates = new Map();
157
- // Check prod dependencies
158
- if (dependent_repo.dependencies?.has(pkg_name)) {
159
- const current = dependent_repo.dependencies.get(pkg_name);
160
- if (needs_update(current, version.new_version)) {
161
- updates.set(pkg_name, version.new_version);
162
- }
163
- }
164
- // Check peer dependencies
165
- if (dependent_repo.peer_dependencies?.has(pkg_name)) {
166
- const current = dependent_repo.peer_dependencies.get(pkg_name);
167
- if (needs_update(current, version.new_version)) {
168
- updates.set(pkg_name, version.new_version);
169
- }
170
- }
171
- // Apply updates if any
172
- if (updates.size > 0) {
173
- log?.info(` Updating ${dependent_repo.library.name}'s dependency on ${pkg_name}`);
174
- emit({
175
- event: 'dependency_updated',
176
- dependent: dependent_repo.library.name,
177
- dependency: pkg_name,
178
- version: version.new_version,
179
- });
180
- changed_repos.add(dependent_repo.library.name); // Mark as changed for deployment
181
- changed_in_iteration.add(dependent_repo.library.name); // Track for batch install
182
- await update_package_json(dependent_repo, updates, {
183
- strategy: options.version_strategy || 'caret',
184
- published_versions: published,
185
- log,
186
- git_ops: ops.git,
187
- });
188
- }
189
- }
192
+ else {
193
+ // update-only leaf: rewrite ranges + commit, no changeset (it won't republish)
194
+ await update_package_json(dependent_repo, updates, {
195
+ strategy: options.version_strategy || 'caret',
196
+ log,
197
+ git_ops: ops.git,
198
+ fs_ops: ops.fs,
199
+ });
190
200
  }
191
201
  }
192
202
  }
193
- catch (error) {
194
- const err = error instanceof Error ? error : new Error(String(error));
195
- failed.set(pkg_name, err);
196
- emit({
197
- event: 'package_failed',
198
- name: pkg_name,
199
- error: err.message,
200
- // TODO: emit a precise code once the npm/process ops return typed errors —
201
- // today a publish-step cause lives in unstructured stderr, so use the honest
202
- // coarse bucket rather than guessing 'auth'/'network'/'build' from the message.
203
- code: 'publish',
204
- });
205
- log?.error(st('red', ` ❌ Failed to publish ${pkg_name}: ${err.message}`));
206
- break; // Always fail fast on error
207
- }
208
- }
209
- // Phase 1b: Batch install dependencies for repos with updated package.json
210
- // This ensures workspace stays consistent before next iteration
211
- if (wetrun && !options.skip_install && changed_in_iteration.size > 0) {
212
- log?.info(st('cyan', '\n📦 Installing dependencies for updated repos...\n'));
213
- for (const [name, err] of await install_repos(changed_in_iteration, repos, ops, emit, log)) {
214
- failed.set(name, err);
215
- }
216
- }
217
- // Log iteration summary
218
- if (published_count > 0) {
219
- log?.info(st('dim', `\nIteration ${iteration}: ${published_count} package(s) ${wetrun ? 'published' : 'would be published'}\n`));
220
203
  }
221
- emit({
222
- event: 'iteration_finished',
223
- iteration,
224
- published_count,
225
- converged: !published_in_iteration,
226
- });
227
- // Check for convergence: no packages published in this iteration
228
- if (!published_in_iteration) {
229
- converged = true;
230
- log?.info(st('green', wetrun
231
- ? `\n✓ Converged after ${iteration} iteration(s) - no new changesets\n`
232
- : `\n✓ Dry run complete after ${iteration} iteration(s)\n`));
233
- }
234
- else if (iteration === GITOPS_MAX_ITERATIONS_DEFAULT) {
235
- // Count packages that still have changesets (not yet published)
236
- const pending_count = order.length - published.size;
237
- const estimated_iterations = Math.ceil(pending_count / 2); // Rough estimate
238
- log?.warn(st('yellow', `\n⚠️ Reached maximum iterations (${GITOPS_MAX_ITERATIONS_DEFAULT}) without full convergence\n` +
239
- ` ${pending_count} package(s) may still have changesets to process\n` +
240
- ` Estimated ${estimated_iterations} more iteration(s) needed - run 'gro gitops_publish' again\n`));
204
+ catch (error) {
205
+ const err = error instanceof Error ? error : new Error(String(error));
206
+ failed.set(pkg_name, err);
207
+ emit({
208
+ event: 'package_failed',
209
+ name: pkg_name,
210
+ error: err.message,
211
+ // TODO: emit a precise code once the npm/process ops return typed errors —
212
+ // today a publish-step cause lives in unstructured stderr, so use the honest
213
+ // coarse bucket rather than guessing 'auth'/'network'/'build' from the message.
214
+ code: 'publish',
215
+ });
216
+ log?.error(st('red', ` ❌ Failed to publish ${pkg_name}: ${err.message}`));
217
+ break; // Always fail fast on error
241
218
  }
242
219
  }
243
220
  // Phase 2: Update all dev dependencies (can have cycles)
244
- // Dev dep changes require deployment even without version bumps (rebuild needed)
245
- const dev_updated_repos = new Set();
246
- if (update_deps && published.size > 0 && wetrun) {
247
- log?.info(st('cyan', '\n🔄 Updating dev dependencies...\n'));
248
- for (const repo of repos) {
249
- const dev_updates = new Map();
250
- // Check dev dependencies only
251
- if (repo.dev_dependencies) {
252
- for (const [dep_name, current_version] of repo.dev_dependencies) {
253
- const published_version = published.get(dep_name);
254
- if (published_version && needs_update(current_version, published_version.new_version)) {
255
- dev_updates.set(dep_name, published_version.new_version);
256
- }
257
- }
258
- }
259
- if (dev_updates.size > 0) {
260
- log?.info(` Updating ${dev_updates.size} dev dependencies in ${repo.library.name}`);
261
- for (const [dep_name, dep_version] of dev_updates) {
262
- emit({
263
- event: 'dependency_updated',
264
- dependent: repo.library.name,
265
- dependency: dep_name,
266
- version: dep_version,
267
- });
268
- }
269
- changed_repos.add(repo.library.name); // Mark as changed for deployment
270
- dev_updated_repos.add(repo.library.name); // Track for batch install
271
- await update_package_json(repo, dev_updates, {
272
- strategy: options.version_strategy || 'caret',
273
- published_versions: published,
274
- log,
275
- git_ops: ops.git,
221
+ // Dev dep changes require deployment even without version bumps (rebuild needed).
222
+ // Sourced from the plan's dependency updates so the publisher derives nothing itself.
223
+ // Only package.json is rewritten + committed here — no install. These repos don't run
224
+ // `gro publish`; their `node_modules` is refreshed (and ETARGET-healed) by gro the next
225
+ // time they build/deploy/sync, so the executor never runs a bare `npm install` itself.
226
+ if (published.size > 0 && wetrun) {
227
+ const dev_updates_by_repo = group_dependency_updates(plan.dependency_updates, published, (update) => update.type === 'devDependencies');
228
+ if (dev_updates_by_repo.size > 0) {
229
+ log?.info(st('cyan', '\n🔄 Updating dev dependencies...\n'));
230
+ }
231
+ for (const [repo_name, dev_updates] of dev_updates_by_repo) {
232
+ const repo = repo_by_name.get(repo_name);
233
+ if (!repo)
234
+ continue;
235
+ log?.info(` Updating ${dev_updates.size} dev dependencies in ${repo_name}`);
236
+ for (const [dep_name, dep_version] of dev_updates) {
237
+ emit({
238
+ event: 'dependency_updated',
239
+ dependent: repo_name,
240
+ dependency: dep_name,
241
+ version: dep_version,
242
+ dep_type: 'dev',
243
+ creates_changeset: false, // dev-dep updates redeploy without republishing
276
244
  });
277
245
  }
278
- }
279
- }
280
- // Phase 2b: Install dev dependencies for repos with dev dep updates
281
- if (wetrun && !options.skip_install && dev_updated_repos.size > 0) {
282
- log?.info(st('cyan', '\n📦 Installing dev dependencies for updated repos...\n'));
283
- for (const [name, err] of await install_repos(dev_updated_repos, repos, ops, emit, log)) {
284
- failed.set(name, err);
246
+ changed_repos.add(repo_name); // Mark as changed for deployment
247
+ // No `published_versions` here on purpose: a dev-dep bump updates and commits
248
+ // package.json but must NOT generate a changeset — dev-only changes redeploy
249
+ // (rebuild) without republishing, so they shouldn't bump the next release.
250
+ await update_package_json(repo, dev_updates, {
251
+ strategy: options.version_strategy || 'caret',
252
+ log,
253
+ git_ops: ops.git,
254
+ fs_ops: ops.fs,
255
+ });
285
256
  }
286
257
  }
287
258
  // Phase 3: Deploy repos with changes (optional)
288
- // Deploys only repos that were: published, had prod/peer deps updated, or had dev deps updated
259
+ // Deploys only repos that were: published, had prod/peer deps updated, or had dev deps updated.
260
+ // Iterate `changed_repos` (insertion order) rather than `repos` order so deploys run in
261
+ // dependency order — and so the `--preview` side-effect list matches this exactly.
289
262
  if (options.deploy && wetrun) {
290
- const repos_to_deploy = repos.filter((r) => changed_repos.has(r.library.name));
263
+ const repos_to_deploy = Array.from(changed_repos)
264
+ .map((name) => repo_by_name.get(name))
265
+ .filter((r) => r !== undefined);
291
266
  log?.info(st('cyan', `\n🚢 Deploying ${repos_to_deploy.length}/${repos.length} repos with changes...\n`));
292
267
  for (const repo of repos_to_deploy) {
293
268
  try {
294
269
  emit({ event: 'deploy_started', name: repo.library.name });
295
270
  log?.info(` Deploying ${repo.library.name}...`);
271
+ // Build fresh (no --no-build): a deployed site bundles its dependencies, so it
272
+ // must be rebuilt against the versions this run just published — the preflight
273
+ // build ran against the old versions, before the cascade rewrote package.json.
296
274
  const deploy_result = await ops.process.spawn({
297
275
  cmd: 'gro',
298
- args: ['deploy', '--no-build'],
276
+ args: ['deploy'],
299
277
  cwd: repo.repo_dir,
300
278
  });
301
279
  if (deploy_result.ok) {
@@ -316,7 +294,7 @@ export const publish_repos = async (repos, options) => {
316
294
  }
317
295
  // Summary
318
296
  const duration = Date.now() - start_time;
319
- const ok = failed.size === 0;
297
+ const ok = failed.size === 0 && plan.errors.length === 0;
320
298
  const summary = summarize_events(capture.events, duration);
321
299
  log?.info(st('cyan', `\n📋 ${wetrun ? 'Publishing' : 'Dry Run'} Summary\n`));
322
300
  log?.info(` Duration: ${(duration / 1000).toFixed(1)}s`);
@@ -324,10 +302,29 @@ export const publish_repos = async (repos, options) => {
324
302
  if (failed.size > 0) {
325
303
  log?.info(` Failed: ${failed.size} packages`);
326
304
  }
305
+ // Surface plan diagnostics (a wetrun with errors threw above; this is mainly the dry run
306
+ // and non-blocking warnings) so an audit of the cascade sees them.
307
+ if (plan.warnings.length > 0) {
308
+ log?.warn(st('yellow', ` ⚠️ Plan warnings: ${plan.warnings.length}`));
309
+ for (const warning of plan.warnings)
310
+ log?.warn(st('yellow', ` - ${warning}`));
311
+ }
312
+ if (plan.errors.length > 0) {
313
+ log?.error(st('red', ` ❌ Plan errors: ${plan.errors.length}`));
314
+ for (const plan_error of plan.errors)
315
+ log?.error(st('red', ` - ${plan_error}`));
316
+ }
327
317
  if (ok) {
328
- log?.info(st('green', wetrun
329
- ? '\n✨ All packages published successfully!\n'
330
- : `\n✨ Dry run complete — ${published.size} package(s) would be published. Re-run with --wetrun to publish.\n`));
318
+ if (wetrun) {
319
+ log?.info(st('green', '\n✨ All packages published successfully!\n'));
320
+ }
321
+ else {
322
+ log?.info(st('green', `\n✨ Dry run complete — ${published.size} package(s) would be published. Re-run with --wetrun to publish.`));
323
+ // The dry run is driven by the same plan as `gro gitops_plan`, so this
324
+ // count includes bump escalations and auto-generated changesets — it
325
+ // matches `gro gitops_plan` exactly (the full cascade).
326
+ log?.info(st('dim', 'This matches `gro gitops_plan` (the full cascade).\n'));
327
+ }
331
328
  }
332
329
  else {
333
330
  log?.error(st('red', wetrun
@@ -342,41 +339,34 @@ export const publish_repos = async (repos, options) => {
342
339
  duration,
343
340
  events: capture.events,
344
341
  summary,
342
+ plan_errors: plan.errors,
343
+ plan_warnings: plan.warnings,
345
344
  };
346
345
  };
347
346
  /**
348
347
  * Publishes a single repo using `gro publish`.
349
348
  *
350
- * Dry run mode: Predicts version from changesets without side effects.
351
- * Real mode: Runs `gro publish --no-build` (builds already validated in preflight),
352
- * reads new version from `package.json`, and returns metadata.
349
+ * Dry run mode: reports the precomputed plan entry without side effects.
350
+ * Real mode: runs `gro publish --no-build` (builds already validated in preflight),
351
+ * reads the new version from `package.json`, and returns it alongside the plan's
352
+ * predicted bump metadata. The caller compares the read-back version to the plan to
353
+ * detect drift.
353
354
  *
354
- * @throws {Error} if changeset prediction fails (dry run) or publish fails (real)
355
+ * @throws {Error} if the publish, version read-back, or commit-hash lookup fails
355
356
  */
356
- const publish_single_repo = async (repo, options, ops = default_gitops_operations) => {
357
- const { wetrun, log } = options;
358
- const old_version = repo.library.package_json.version || '0.0.0';
357
+ const publish_single_repo = async (repo, options, ops, planned) => {
358
+ const { wetrun } = options;
359
359
  if (!wetrun) {
360
- // In dry run, predict version from changesets
361
- const prediction = await ops.changeset.predict_next_version({ repo, log });
362
- if (!prediction) {
363
- // No changesets found, skip this repo
364
- throw new Error(`No changesets found for ${repo.library.name}`);
365
- }
366
- if (!prediction.ok) {
367
- // Error reading changesets
368
- throw new Error(`Failed to predict version: ${prediction.message}`);
369
- }
370
- const { version: new_version, bump_type } = prediction;
371
- const breaking = is_breaking_change(old_version, bump_type);
360
+ // Dry run reports the precomputed plan — the single source of truth for the
361
+ // cascade (explicit changesets, bump escalations, and auto-generated changesets).
372
362
  return {
373
363
  name: repo.library.name,
374
- old_version,
375
- new_version,
376
- bump_type,
377
- breaking,
364
+ old_version: planned.from,
365
+ new_version: planned.to,
366
+ bump_type: planned.bump_type,
367
+ breaking: planned.breaking,
378
368
  commit: 'simulated',
379
- tag: `v${new_version}`,
369
+ tag: `v${planned.to}`,
380
370
  };
381
371
  }
382
372
  // Run gro publish with --no-build (builds were validated in preflight checks)
@@ -396,50 +386,52 @@ const publish_single_repo = async (repo, options, ops = default_gitops_operation
396
386
  }
397
387
  const package_json = JSON.parse(content_result.value);
398
388
  const new_version = package_json.version;
399
- // Determine bump type and if it's breaking
400
- const bump_type = detect_bump_type(old_version, new_version);
401
- const breaking = is_breaking_change(old_version, bump_type);
402
389
  // Get actual commit hash
403
390
  const commit_result = await ops.git.current_commit_hash({ cwd: repo.repo_dir });
404
391
  if (!commit_result.ok) {
405
392
  throw new Error(`Failed to get commit hash: ${commit_result.message}`);
406
393
  }
407
394
  const commit = commit_result.value;
395
+ // Bump metadata comes from the plan (the single source of truth); the caller
396
+ // fail-louds if `new_version` diverges from the plan's prediction.
408
397
  return {
409
398
  name: repo.library.name,
410
- old_version,
399
+ old_version: planned.from,
411
400
  new_version,
412
- bump_type,
413
- breaking,
401
+ bump_type: planned.bump_type,
402
+ breaking: planned.breaking,
414
403
  commit,
415
404
  tag: `v${new_version}`,
416
405
  };
417
406
  };
418
407
  /**
419
- * Installs dependencies for each named repo (with cache healing), emitting install
420
- * events and logging progress. A failed install doesn't stop the batch — failures are
421
- * collected and returned so the caller can fold them into the run's failures.
408
+ * Groups dependency updates by dependent package — `dependent → (dependency → new
409
+ * version)`, the shape `update_package_json` consumes. Restricted by `predicate` (e.g.
410
+ * prod/peer for a given package, or all dev deps) and to dependencies that actually
411
+ * published this run, so a failed/aborted publish never propagates to its dependents.
422
412
  */
423
- const install_repos = async (names, repos, ops, emit, log) => {
424
- const failures = new Map();
425
- for (const name of names) {
426
- const repo = repos.find((r) => r.library.name === name);
427
- if (!repo)
413
+ export const group_dependency_updates = (updates, published, predicate) => {
414
+ const by_repo = new Map();
415
+ for (const update of updates) {
416
+ if (!predicate(update))
428
417
  continue;
429
- try {
430
- emit({ event: 'install_started', name });
431
- log?.info(` Installing ${name}...`);
432
- await install_with_cache_healing(repo, ops, log);
433
- emit({ event: 'install_completed', name });
434
- log?.info(st('green', ` ✅ Installed ${name}`));
435
- }
436
- catch (error) {
437
- const err = error instanceof Error ? error : new Error(String(error));
438
- failures.set(name, err);
439
- emit({ event: 'install_failed', name, error: err.message });
440
- log?.error(st('red', ` ❌ Failed to install ${name}: ${err.message}`));
441
- // continue with other installs instead of breaking
418
+ const published_dep = published.get(update.updated_dependency);
419
+ if (!published_dep)
420
+ continue;
421
+ let repo_updates = by_repo.get(update.dependent_package);
422
+ if (!repo_updates) {
423
+ repo_updates = new Map();
424
+ by_repo.set(update.dependent_package, repo_updates);
442
425
  }
426
+ repo_updates.set(update.updated_dependency, published_dep.new_version);
443
427
  }
444
- return failures;
428
+ return by_repo;
445
429
  };
430
+ /** The dep_type tag for a prod/peer dependency-update event — `peer` if the edge is a peer
431
+ * dependency, else `prod`. Mirrors `derive_publish_steps`' classification so the executor's
432
+ * event stream and the preview agree. */
433
+ const dependency_update_type = (plan, dependent, dependency) => plan.dependency_updates.some((u) => u.dependent_package === dependent &&
434
+ u.updated_dependency === dependency &&
435
+ u.type === 'peerDependencies')
436
+ ? 'peer'
437
+ : 'prod';