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