@fuzdev/fuz_gitops 0.78.0 → 0.79.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 (196) hide show
  1. package/README.md +91 -17
  2. package/dist/ModulesDetail.svelte +22 -27
  3. package/dist/ModulesDetail.svelte.d.ts.map +1 -1
  4. package/dist/ModulesNav.svelte +8 -5
  5. package/dist/ModulesNav.svelte.d.ts.map +1 -1
  6. package/dist/ModulesPage.svelte +3 -5
  7. package/dist/ModulesPage.svelte.d.ts.map +1 -1
  8. package/dist/PageFooter.svelte +2 -2
  9. package/dist/PageHeader.svelte +6 -6
  10. package/dist/PageHeader.svelte.d.ts +0 -3
  11. package/dist/PageHeader.svelte.d.ts.map +1 -1
  12. package/dist/PullRequestsDetail.svelte +29 -20
  13. package/dist/PullRequestsDetail.svelte.d.ts.map +1 -1
  14. package/dist/PullRequestsPage.svelte +4 -6
  15. package/dist/PullRequestsPage.svelte.d.ts.map +1 -1
  16. package/dist/ReposTable.svelte +43 -55
  17. package/dist/ReposTable.svelte.d.ts.map +1 -1
  18. package/dist/ReposTree.svelte +9 -21
  19. package/dist/ReposTree.svelte.d.ts.map +1 -1
  20. package/dist/ReposTreeNav.svelte +9 -12
  21. package/dist/ReposTreeNav.svelte.d.ts.map +1 -1
  22. package/dist/TablePage.svelte +3 -5
  23. package/dist/TablePage.svelte.d.ts.map +1 -1
  24. package/dist/TreeItemPage.svelte +3 -5
  25. package/dist/TreeItemPage.svelte.d.ts.map +1 -1
  26. package/dist/TreePage.svelte +3 -5
  27. package/dist/TreePage.svelte.d.ts.map +1 -1
  28. package/dist/changeset_generator.d.ts.map +1 -1
  29. package/dist/changeset_generator.js +2 -2
  30. package/dist/changeset_reader.d.ts.map +1 -1
  31. package/dist/changeset_reader.js +3 -3
  32. package/dist/ci_reconcile.d.ts +6 -10
  33. package/dist/ci_reconcile.d.ts.map +1 -1
  34. package/dist/ci_reconcile.js +6 -8
  35. package/dist/dependency_graph.d.ts +26 -49
  36. package/dist/dependency_graph.d.ts.map +1 -1
  37. package/dist/dependency_graph.js +40 -83
  38. package/dist/dependency_updater.d.ts +1 -1
  39. package/dist/dependency_updater.d.ts.map +1 -1
  40. package/dist/dependency_updater.js +14 -9
  41. package/dist/fetch_repo_data.d.ts +34 -8
  42. package/dist/fetch_repo_data.d.ts.map +1 -1
  43. package/dist/fetch_repo_data.js +44 -22
  44. package/dist/fs_fetch_value_cache.d.ts +1 -1
  45. package/dist/fs_fetch_value_cache.d.ts.map +1 -1
  46. package/dist/fs_fetch_value_cache.js +3 -3
  47. package/dist/git_operations.d.ts +6 -49
  48. package/dist/git_operations.d.ts.map +1 -1
  49. package/dist/git_operations.js +13 -126
  50. package/dist/github.d.ts +17 -2
  51. package/dist/github.d.ts.map +1 -1
  52. package/dist/github.js +17 -8
  53. package/dist/github_helpers.d.ts.map +1 -1
  54. package/dist/github_helpers.js +3 -11
  55. package/dist/gitops_analyze.task.d.ts +24 -2
  56. package/dist/gitops_analyze.task.d.ts.map +1 -1
  57. package/dist/gitops_analyze.task.js +47 -34
  58. package/dist/gitops_config.d.ts +34 -79
  59. package/dist/gitops_config.d.ts.map +1 -1
  60. package/dist/gitops_config.js +76 -53
  61. package/dist/gitops_constants.d.ts +6 -4
  62. package/dist/gitops_constants.d.ts.map +1 -1
  63. package/dist/gitops_constants.js +6 -4
  64. package/dist/gitops_plan.task.d.ts +29 -3
  65. package/dist/gitops_plan.task.d.ts.map +1 -1
  66. package/dist/gitops_plan.task.js +83 -61
  67. package/dist/gitops_publish.task.d.ts +62 -2
  68. package/dist/gitops_publish.task.d.ts.map +1 -1
  69. package/dist/gitops_publish.task.js +193 -105
  70. package/dist/gitops_run.task.d.ts +74 -0
  71. package/dist/gitops_run.task.d.ts.map +1 -1
  72. package/dist/gitops_run.task.js +176 -162
  73. package/dist/gitops_sync.task.d.ts +65 -3
  74. package/dist/gitops_sync.task.d.ts.map +1 -1
  75. package/dist/gitops_sync.task.js +163 -37
  76. package/dist/gitops_task_helpers.d.ts +86 -59
  77. package/dist/gitops_task_helpers.d.ts.map +1 -1
  78. package/dist/gitops_task_helpers.js +120 -79
  79. package/dist/gitops_validate.task.d.ts +1 -2
  80. package/dist/gitops_validate.task.d.ts.map +1 -1
  81. package/dist/gitops_validate.task.js +25 -32
  82. package/dist/graph_validation.d.ts +16 -27
  83. package/dist/graph_validation.d.ts.map +1 -1
  84. package/dist/graph_validation.js +24 -65
  85. package/dist/local_repo.d.ts +50 -62
  86. package/dist/local_repo.d.ts.map +1 -1
  87. package/dist/local_repo.js +83 -196
  88. package/dist/log_helpers.d.ts +8 -8
  89. package/dist/log_helpers.d.ts.map +1 -1
  90. package/dist/multi_repo_publisher.d.ts +10 -0
  91. package/dist/multi_repo_publisher.d.ts.map +1 -1
  92. package/dist/multi_repo_publisher.js +125 -61
  93. package/dist/npm_registry.d.ts +23 -19
  94. package/dist/npm_registry.d.ts.map +1 -1
  95. package/dist/npm_registry.js +21 -48
  96. package/dist/operations.d.ts +75 -180
  97. package/dist/operations.d.ts.map +1 -1
  98. package/dist/operations.js +15 -7
  99. package/dist/operations_defaults.d.ts +17 -2
  100. package/dist/operations_defaults.d.ts.map +1 -1
  101. package/dist/operations_defaults.js +111 -128
  102. package/dist/output_helpers.d.ts +36 -0
  103. package/dist/output_helpers.d.ts.map +1 -1
  104. package/dist/output_helpers.js +37 -3
  105. package/dist/preflight_checks.d.ts +20 -27
  106. package/dist/preflight_checks.d.ts.map +1 -1
  107. package/dist/preflight_checks.js +37 -126
  108. package/dist/publish_gate.d.ts.map +1 -1
  109. package/dist/publish_steps.d.ts +2 -4
  110. package/dist/publish_steps.d.ts.map +1 -1
  111. package/dist/publish_steps.js +4 -8
  112. package/dist/publishing_event.d.ts +2 -8
  113. package/dist/publishing_event.d.ts.map +1 -1
  114. package/dist/publishing_event.js +20 -17
  115. package/dist/publishing_event_handler.d.ts +10 -8
  116. package/dist/publishing_event_handler.d.ts.map +1 -1
  117. package/dist/publishing_event_handler.js +19 -17
  118. package/dist/publishing_plan.d.ts +16 -1
  119. package/dist/publishing_plan.d.ts.map +1 -1
  120. package/dist/publishing_plan.js +332 -254
  121. package/dist/publishing_plan_helpers.d.ts.map +1 -1
  122. package/dist/publishing_plan_helpers.js +3 -3
  123. package/dist/publishing_plan_logging.d.ts +1 -1
  124. package/dist/publishing_plan_logging.d.ts.map +1 -1
  125. package/dist/publishing_plan_logging.js +22 -15
  126. package/dist/repo.svelte.d.ts +7 -0
  127. package/dist/repo.svelte.d.ts.map +1 -1
  128. package/dist/repo.svelte.js +3 -0
  129. package/dist/repo_readiness.d.ts +210 -0
  130. package/dist/repo_readiness.d.ts.map +1 -0
  131. package/dist/repo_readiness.js +516 -0
  132. package/dist/repos_status.d.ts +3340 -0
  133. package/dist/repos_status.d.ts.map +1 -0
  134. package/dist/repos_status.js +555 -0
  135. package/dist/repos_status_load.d.ts +65 -0
  136. package/dist/repos_status_load.d.ts.map +1 -0
  137. package/dist/repos_status_load.js +131 -0
  138. package/dist/version_utils.js +1 -1
  139. package/package.json +15 -34
  140. package/src/lib/cargo_toml.ts +3 -3
  141. package/src/lib/changeset_generator.ts +19 -19
  142. package/src/lib/changeset_reader.ts +18 -18
  143. package/src/lib/ci_reconcile.ts +12 -16
  144. package/src/lib/dependency_graph.ts +61 -113
  145. package/src/lib/dependency_updater.ts +36 -30
  146. package/src/lib/fetch_repo_data.ts +71 -34
  147. package/src/lib/fs_fetch_value_cache.ts +15 -11
  148. package/src/lib/git_operations.ts +22 -194
  149. package/src/lib/github.ts +33 -21
  150. package/src/lib/github_helpers.ts +9 -16
  151. package/src/lib/gitops_analyze.task.ts +104 -66
  152. package/src/lib/gitops_config.ts +90 -139
  153. package/src/lib/gitops_constants.ts +6 -4
  154. package/src/lib/gitops_plan.task.ts +128 -76
  155. package/src/lib/gitops_publish.task.ts +280 -144
  156. package/src/lib/gitops_run.task.ts +265 -193
  157. package/src/lib/gitops_sync.task.ts +236 -52
  158. package/src/lib/gitops_task_helpers.ts +170 -114
  159. package/src/lib/gitops_validate.task.ts +44 -54
  160. package/src/lib/graph_validation.ts +28 -89
  161. package/src/lib/local_repo.ts +128 -335
  162. package/src/lib/log_helpers.ts +18 -28
  163. package/src/lib/multi_repo_publisher.ts +192 -110
  164. package/src/lib/npm_registry.ts +42 -67
  165. package/src/lib/operations.ts +99 -160
  166. package/src/lib/operations_defaults.ts +157 -201
  167. package/src/lib/output_helpers.ts +58 -6
  168. package/src/lib/preflight_checks.ts +52 -188
  169. package/src/lib/publish_gate.ts +7 -9
  170. package/src/lib/publish_steps.ts +27 -25
  171. package/src/lib/publishing_event.ts +26 -23
  172. package/src/lib/publishing_event_handler.ts +24 -22
  173. package/src/lib/publishing_plan.ts +474 -310
  174. package/src/lib/publishing_plan_helpers.ts +16 -12
  175. package/src/lib/publishing_plan_logging.ts +53 -44
  176. package/src/lib/repo.svelte.ts +16 -8
  177. package/src/lib/repo_readiness.ts +647 -0
  178. package/src/lib/repos_status.ts +671 -0
  179. package/src/lib/repos_status_load.ts +173 -0
  180. package/src/lib/version_utils.ts +3 -3
  181. package/dist/config_reconcile.d.ts +0 -46
  182. package/dist/config_reconcile.d.ts.map +0 -1
  183. package/dist/config_reconcile.js +0 -60
  184. package/dist/paths.d.ts +0 -7
  185. package/dist/paths.d.ts.map +0 -1
  186. package/dist/paths.js +0 -6
  187. package/dist/repo_ops.d.ts +0 -59
  188. package/dist/repo_ops.d.ts.map +0 -1
  189. package/dist/repo_ops.js +0 -170
  190. package/dist/resolved_gitops_config.d.ts +0 -9
  191. package/dist/resolved_gitops_config.d.ts.map +0 -1
  192. package/dist/resolved_gitops_config.js +0 -12
  193. package/src/lib/config_reconcile.ts +0 -94
  194. package/src/lib/paths.ts +0 -6
  195. package/src/lib/repo_ops.ts +0 -216
  196. package/src/lib/resolved_gitops_config.ts +0 -27
@@ -1,29 +1,32 @@
1
- import type {Logger} from '@fuzdev/fuz_util/log.ts';
2
- import {TaskError} from '@fuzdev/gro';
3
- import {join} from 'node:path';
4
- import {styleText as st} from 'node:util';
1
+ import type { Logger } from '@fuzdev/fuz_util/log.ts';
2
+ import { TaskError } from '@fuzdev/gro';
3
+ import { join } from 'node:path';
4
+ import { styleText as st } from 'node:util';
5
5
 
6
- import {repo_is_npm, type LocalRepo} from './local_repo.ts';
7
- import {update_package_json, type VersionStrategy} from './dependency_updater.ts';
6
+ import { repo_is_npm, type LocalRepo } from './local_repo.ts';
7
+ import { update_package_json, type VersionStrategy } from './dependency_updater.ts';
8
8
  import {
9
9
  generate_publishing_plan,
10
10
  type VersionChange,
11
11
  type DependencyUpdate,
12
- type PublishingPlan,
12
+ type PublishingPlan
13
13
  } from './publishing_plan.ts';
14
- import type {PreflightOptions} from './preflight_checks.ts';
15
- import type {GitopsOperations} from './operations.ts';
16
- import {default_gitops_operations} from './operations_defaults.ts';
17
- import {GITOPS_NPM_WAIT_TIMEOUT_DEFAULT} from './gitops_constants.ts';
14
+ import { load_repos_status, to_repos_command } from './repos_status_load.ts';
15
+ import { check_publish_readiness } from './repo_readiness.ts';
16
+ import type { Result } from '@fuzdev/fuz_util/result.ts';
17
+ import type { GitopsOperations } from './operations.ts';
18
+ import { default_gitops_operations } from './operations_defaults.ts';
19
+ import { GITOPS_NPM_WAIT_TIMEOUT_DEFAULT } from './gitops_constants.ts';
18
20
  import {
19
21
  type PublishingEvent,
20
22
  type PublishingRunSummary,
21
- summarize_events,
23
+ summarize_events
22
24
  } from './publishing_event.ts';
23
25
  import {
24
26
  type PublishingEventHandler,
25
27
  capture_handler,
26
28
  multi_handler,
29
+ redact_secrets
27
30
  } from './publishing_event_handler.ts';
28
31
 
29
32
  export interface PublishingOptions {
@@ -33,8 +36,18 @@ export interface PublishingOptions {
33
36
  max_wait?: number;
34
37
  log?: Logger;
35
38
  ops?: GitopsOperations;
39
+ /** A `repos.toml` for the per-repo readiness re-check, when `repos` wouldn't find it from the cwd. */
40
+ registry?: string;
36
41
  /** Structured event sink; defaults to capture-only (events surface on the result). */
37
42
  events?: PublishingEventHandler;
43
+ /**
44
+ * Where `gro publish` and `gro deploy` show their stdout: ours, or our stderr
45
+ * when our stdout carries a machine-readable stream (`--emit_json`, or a JSON
46
+ * or markdown report written to stdout).
47
+ *
48
+ * @default 'stdout'
49
+ */
50
+ child_stdout?: 'stdout' | 'stderr';
38
51
  }
39
52
 
40
53
  export interface PublishedVersion {
@@ -50,7 +63,7 @@ export interface PublishedVersion {
50
63
  export interface PublishingResult {
51
64
  ok: boolean;
52
65
  published: Array<PublishedVersion>;
53
- failed: Array<{name: string; error: Error}>;
66
+ failed: Array<{ name: string; error: Error }>;
54
67
  duration: number;
55
68
  /** The structured event stream for this run, in emission order. */
56
69
  events: Array<PublishingEvent>;
@@ -64,13 +77,13 @@ export interface PublishingResult {
64
77
 
65
78
  export const publish_repos = async (
66
79
  repos: Array<LocalRepo>,
67
- options: PublishingOptions,
80
+ options: PublishingOptions
68
81
  ): Promise<PublishingResult> => {
69
- const {log, ops = default_gitops_operations} = options;
82
+ const { log, ops = default_gitops_operations } = options;
70
83
  // Convenience entry: generate the plan, then execute it. Callers that already hold a
71
84
  // plan (e.g. to display and confirm it first) call `execute_publishing_plan` directly,
72
85
  // so the plan is generated exactly once per command.
73
- const plan = await generate_publishing_plan(repos, {log, ops: ops.changeset});
86
+ const plan = await generate_publishing_plan(repos, { log, ops: ops.changeset });
74
87
  return execute_publishing_plan(repos, plan, options);
75
88
  };
76
89
 
@@ -85,10 +98,11 @@ export const publish_repos = async (
85
98
  export const execute_publishing_plan = async (
86
99
  all_repos: Array<LocalRepo>,
87
100
  plan: PublishingPlan,
88
- options: PublishingOptions,
101
+ options: PublishingOptions
89
102
  ): Promise<PublishingResult> => {
90
103
  const start_time = Date.now();
91
- const {wetrun, log, ops = default_gitops_operations} = options;
104
+ const { wetrun, log, ops = default_gitops_operations } = options;
105
+ const version_strategy = options.version_strategy ?? 'caret';
92
106
 
93
107
  // Only npm repos publish; drop any non-npm repos (e.g. cargo) so preflight and the
94
108
  // executor's name lookup never touch them. The plan already excludes them too.
@@ -99,7 +113,7 @@ export const execute_publishing_plan = async (
99
113
  // (no side effects) but the result is not ok (see the summary below).
100
114
  if (wetrun && plan.errors.length > 0) {
101
115
  throw new TaskError(
102
- `Cannot publish — the plan has ${plan.errors.length} error(s):\n ${plan.errors.join('\n ')}`,
116
+ `Cannot publish — the plan has ${plan.errors.length} error(s):\n ${plan.errors.join('\n ')}`
103
117
  );
104
118
  }
105
119
 
@@ -110,20 +124,16 @@ export const execute_publishing_plan = async (
110
124
  events_handler.emit(event);
111
125
  };
112
126
 
113
- // Preflight checks (skip for dry runs since we're not actually publishing)
127
+ // Preflight checks (skip for dry runs since we're not actually publishing): it builds
128
+ // exactly the packages the plan publishes. Repo git state isn't preflight's:
129
+ // `gitops_publish --wetrun` gates on it before its confirmation prompt.
114
130
  if (wetrun) {
115
- const preflight_options: PreflightOptions = {
116
- skip_changesets: false, // Always check for changesets
117
- required_branch: 'main',
118
- log,
119
- };
120
131
  const preflight = await ops.preflight.run_preflight_checks({
121
132
  repos,
122
- preflight_options,
123
- git_ops: ops.git,
133
+ version_changes: plan.version_changes,
134
+ log,
124
135
  npm_ops: ops.npm,
125
- build_ops: ops.build,
126
- changeset_ops: ops.changeset,
136
+ build_ops: ops.build
127
137
  });
128
138
 
129
139
  if (!preflight.ok) {
@@ -138,13 +148,13 @@ export const execute_publishing_plan = async (
138
148
  // frozen topological order directly, re-deriving nothing.
139
149
  const order = plan.publishing_order;
140
150
  const plan_changes: Map<string, VersionChange> = new Map(
141
- plan.version_changes.map((vc) => [vc.package_name, vc]),
151
+ plan.version_changes.map((vc) => [vc.package_name, vc])
142
152
  );
143
153
  if (order.length > 0) {
144
154
  log?.info(` Publishing order: ${order.join(' → ')}`);
145
155
  }
146
156
 
147
- emit({event: 'run_started', wetrun, total: order.length});
157
+ emit({ event: 'run_started', wetrun, total: order.length });
148
158
 
149
159
  const published: Map<string, PublishedVersion> = new Map();
150
160
  const failed: Map<string, Error> = new Map();
@@ -165,7 +175,7 @@ export const execute_publishing_plan = async (
165
175
 
166
176
  // Not in the plan = no changesets and no dependency updates = nothing to publish.
167
177
  if (!planned) {
168
- emit({event: 'package_skipped', name: pkg_name, reason: 'no changesets'});
178
+ emit({ event: 'package_skipped', name: pkg_name, reason: 'no changesets' });
169
179
  log?.info(st('yellow', ` ⚠️ Skipping ${pkg_name} - no changesets`));
170
180
  continue;
171
181
  }
@@ -173,6 +183,26 @@ export const execute_publishing_plan = async (
173
183
  const repo = repo_by_name.get(pkg_name);
174
184
  if (!repo) continue;
175
185
 
186
+ // Re-check the repo right before its `gro publish`: the gate ran before the prompt, and a
187
+ // cascade's npm waits leave a long window. `--no-pull` means gro won't notice origin
188
+ // moving until its push is rejected — after `changeset publish` put the version on npm —
189
+ // and its `commit -a` would sweep a tracked edit made since into the release commit.
190
+ // Aborting here, before any npm side effect, leaves the dirty state resumption expects.
191
+ if (wetrun) {
192
+ const recheck = await recheck_publish_readiness(repo, options.registry, ops);
193
+ if (!recheck.ok) {
194
+ const err = new Error(
195
+ `${pkg_name} isn't ready to publish, re-checked right before \`gro publish\`: ` +
196
+ `${recheck.message}. Aborting before it touches npm — fix it, then re-run ` +
197
+ `'gro gitops_publish --wetrun' to re-plan from the current state.`
198
+ );
199
+ failed.set(pkg_name, err);
200
+ emit({ event: 'package_failed', name: pkg_name, error: err.message, code: 'not_ready' });
201
+ log?.error(st('red', ` ❌ ${err.message}`));
202
+ break;
203
+ }
204
+ }
205
+
176
206
  // An earlier publish may have rewritten this package's dependency ranges. No install is
177
207
  // needed here: `gro publish` runs its own install (which self-heals npm's stale-cache
178
208
  // ETARGET), so the package's freshly-rewritten deps are installed and healed as part of
@@ -182,8 +212,8 @@ export const execute_publishing_plan = async (
182
212
  log?.info(
183
213
  st(
184
214
  'dim',
185
- ` [${i + 1}/${order.length}] ${wetrun ? 'Publishing' : 'Would publish'} ${pkg_name}...`,
186
- ),
215
+ ` [${i + 1}/${order.length}] ${wetrun ? 'Publishing' : 'Would publish'} ${pkg_name}...`
216
+ )
187
217
  );
188
218
  const version = await publish_single_repo(repo, options, ops, planned);
189
219
 
@@ -194,10 +224,10 @@ export const execute_publishing_plan = async (
194
224
  if (wetrun && version.new_version !== planned.to) {
195
225
  const err = new Error(
196
226
  `Plan drift for ${pkg_name}: published ${version.new_version} but the plan predicted ${planned.to}. ` +
197
- `Aborting — re-run 'gro gitops_publish --wetrun' to re-plan from the current state.`,
227
+ `Aborting — re-run 'gro gitops_publish --wetrun' to re-plan from the current state.`
198
228
  );
199
229
  failed.set(pkg_name, err);
200
- emit({event: 'package_failed', name: pkg_name, error: err.message, code: 'drift'});
230
+ emit({ event: 'package_failed', name: pkg_name, error: err.message, code: 'drift' });
201
231
  log?.error(st('red', ` ❌ ${err.message}`));
202
232
  break;
203
233
  }
@@ -212,12 +242,12 @@ export const execute_publishing_plan = async (
212
242
  bump_type: version.bump_type,
213
243
  breaking: version.breaking,
214
244
  commit: version.commit,
215
- tag: version.tag,
245
+ tag: version.tag
216
246
  });
217
247
  log?.info(
218
248
  wetrun
219
249
  ? st('green', ` ✅ Published ${pkg_name}@${version.new_version}`)
220
- : st('cyan', ` ◇ Would publish ${pkg_name}@${version.new_version}`),
250
+ : st('cyan', ` ◇ Would publish ${pkg_name}@${version.new_version}`)
221
251
  );
222
252
 
223
253
  if (wetrun) {
@@ -230,35 +260,32 @@ export const execute_publishing_plan = async (
230
260
  max_attempts: 30,
231
261
  initial_delay: 1000,
232
262
  max_delay: 60000,
233
- timeout: options.max_wait ?? GITOPS_NPM_WAIT_TIMEOUT_DEFAULT,
263
+ timeout: options.max_wait ?? GITOPS_NPM_WAIT_TIMEOUT_DEFAULT
234
264
  },
235
- log,
265
+ log
236
266
  });
237
267
 
238
268
  if (!wait_result.ok) {
239
- // Handle inline (don't throw into the generic catch): the npm-wait failure
240
- // carries a typed `timeout` signal, so we know this is a network failure
241
- // without sniffing the message.
242
- const err = new Error(
243
- `Failed to wait for package: ${wait_result.message}${wait_result.timeout ? ' (timeout)' : ''}`,
244
- );
269
+ // Handle inline (don't throw into the generic catch): a failed npm wait is a
270
+ // network failure, known by where it happened rather than by sniffing the message.
271
+ const err = new Error(`Failed to wait for package: ${wait_result.message}`);
245
272
  failed.set(pkg_name, err);
246
- emit({event: 'package_failed', name: pkg_name, error: err.message, code: 'network'});
273
+ emit({ event: 'package_failed', name: pkg_name, error: err.message, code: 'network' });
247
274
  log?.error(st('red', ` ❌ Failed to publish ${pkg_name}: ${err.message}`));
248
275
  break; // fail fast
249
276
  }
250
- emit({event: 'npm_waited', name: pkg_name, version: version.new_version});
277
+ emit({ event: 'npm_waited', name: pkg_name, version: version.new_version });
251
278
 
252
279
  // 3. Update every dependent the plan says has a prod/peer dep on this package.
253
280
  // This rewrites their package.json ranges and creates their auto-changeset,
254
- // which a later step of this same pass publishes. The dependent is queued for
255
- // a single install just before it publishes (see the top of the loop).
281
+ // which a later step of this same pass publishes; its `gro publish` installs the
282
+ // rewritten deps then (see the top of the loop).
256
283
  const dependent_updates = group_dependency_updates(
257
284
  plan.dependency_updates,
258
285
  published,
259
286
  (update) =>
260
287
  update.updated_dependency === pkg_name &&
261
- (update.type === 'dependencies' || update.type === 'peerDependencies'),
288
+ (update.type === 'dependencies' || update.type === 'peerDependencies')
262
289
  );
263
290
  for (const [dependent_name, updates] of dependent_updates) {
264
291
  const dependent_repo = repo_by_name.get(dependent_name);
@@ -277,27 +304,19 @@ export const execute_publishing_plan = async (
277
304
  dependency: dep_name,
278
305
  version: dep_version,
279
306
  dep_type: dependency_update_type(plan, dependent_name, dep_name),
280
- creates_changeset: republishes,
307
+ creates_changeset: republishes
281
308
  });
282
309
  }
283
310
  changed_repos.add(dependent_name); // Mark as changed for deployment
284
- if (republishes) {
285
- await update_package_json(dependent_repo, updates, {
286
- strategy: options.version_strategy || 'caret',
287
- published_versions: published, // creates the auto-changeset
288
- log,
289
- git_ops: ops.git,
290
- fs_ops: ops.fs,
291
- });
292
- } else {
293
- // update-only leaf: rewrite ranges + commit, no changeset (it won't republish)
294
- await update_package_json(dependent_repo, updates, {
295
- strategy: options.version_strategy || 'caret',
296
- log,
297
- git_ops: ops.git,
298
- fs_ops: ops.fs,
299
- });
300
- }
311
+ await update_package_json(dependent_repo, updates, {
312
+ strategy: version_strategy,
313
+ // `published_versions` creates the auto-changeset; an update-only leaf gets
314
+ // its ranges rewritten and committed with no changeset (it won't republish)
315
+ published_versions: republishes ? published : undefined,
316
+ log,
317
+ git_ops: ops.git,
318
+ fs_ops: ops.fs
319
+ });
301
320
  }
302
321
  }
303
322
  } catch (error) {
@@ -309,10 +328,11 @@ export const execute_publishing_plan = async (
309
328
  error: err.message,
310
329
  // TODO: emit a precise code once the npm/process ops return typed errors —
311
330
  // today a publish-step cause lives in unstructured stderr, so use the honest
312
- // coarse bucket rather than guessing 'auth'/'network'/'build' from the message.
313
- code: 'publish',
331
+ // coarse bucket rather than guessing a cause (auth, build, …) from the message.
332
+ code: 'publish'
314
333
  });
315
- log?.error(st('red', ` ❌ Failed to publish ${pkg_name}: ${err.message}`));
334
+ // the first line alone: the stderr tail the rest repeats just streamed live
335
+ log?.error(st('red', ` ❌ Failed to publish ${pkg_name}: ${err.message.split('\n')[0]}`));
316
336
  break; // Always fail fast on error
317
337
  }
318
338
  }
@@ -327,7 +347,7 @@ export const execute_publishing_plan = async (
327
347
  const dev_updates_by_repo = group_dependency_updates(
328
348
  plan.dependency_updates,
329
349
  published,
330
- (update) => update.type === 'devDependencies',
350
+ (update) => update.type === 'devDependencies'
331
351
  );
332
352
 
333
353
  if (dev_updates_by_repo.size > 0) {
@@ -346,7 +366,7 @@ export const execute_publishing_plan = async (
346
366
  dependency: dep_name,
347
367
  version: dep_version,
348
368
  dep_type: 'dev',
349
- creates_changeset: false, // dev-dep updates redeploy without republishing
369
+ creates_changeset: false // dev-dep updates redeploy without republishing
350
370
  });
351
371
  }
352
372
  changed_repos.add(repo_name); // Mark as changed for deployment
@@ -354,10 +374,10 @@ export const execute_publishing_plan = async (
354
374
  // package.json but must NOT generate a changeset — dev-only changes redeploy
355
375
  // (rebuild) without republishing, so they shouldn't bump the next release.
356
376
  await update_package_json(repo, dev_updates, {
357
- strategy: options.version_strategy || 'caret',
377
+ strategy: version_strategy,
358
378
  log,
359
379
  git_ops: ops.git,
360
- fs_ops: ops.fs,
380
+ fs_ops: ops.fs
361
381
  });
362
382
  }
363
383
  }
@@ -371,35 +391,40 @@ export const execute_publishing_plan = async (
371
391
  .map((name) => repo_by_name.get(name))
372
392
  .filter((r): r is LocalRepo => r !== undefined);
373
393
  log?.info(
374
- st(
375
- 'cyan',
376
- `\n🚢 Deploying ${repos_to_deploy.length}/${repos.length} repos with changes...\n`,
377
- ),
394
+ st('cyan', `\n🚢 Deploying ${repos_to_deploy.length}/${repos.length} repos with changes...\n`)
378
395
  );
379
396
 
380
397
  for (const repo of repos_to_deploy) {
381
398
  try {
382
- emit({event: 'deploy_started', name: repo.library.name});
399
+ emit({ event: 'deploy_started', name: repo.library.name });
383
400
  log?.info(` Deploying ${repo.library.name}...`);
384
401
  // Build fresh (no --no-build): a deployed site bundles its dependencies, so it
385
402
  // must be rebuilt against the versions this run just published — the preflight
386
403
  // build ran against the old versions, before the cascade rewrote package.json.
387
- const deploy_result = await ops.process.spawn({
404
+ const deploy_result = await ops.process.run_interactive({
388
405
  cmd: 'gro',
389
406
  args: ['deploy'],
390
407
  cwd: repo.repo_dir,
408
+ stdout: options.child_stdout
391
409
  });
392
410
 
393
411
  if (deploy_result.ok) {
394
- emit({event: 'deploy_completed', name: repo.library.name});
412
+ emit({ event: 'deploy_completed', name: repo.library.name });
395
413
  log?.info(st('green', ` ✅ Deployed ${repo.library.name}`));
396
414
  } else {
397
- emit({event: 'deploy_failed', name: repo.library.name, error: deploy_result.message});
398
- log?.warn(st('yellow', ` ⚠️ Failed to deploy ${repo.library.name}`));
415
+ emit({
416
+ event: 'deploy_failed',
417
+ name: repo.library.name,
418
+ error: format_run_failure(deploy_result)
419
+ });
420
+ // the stderr tail just streamed live, so the log names the failure alone
421
+ log?.warn(
422
+ st('yellow', ` ⚠️ Failed to deploy ${repo.library.name}: ${deploy_result.message}`)
423
+ );
399
424
  }
400
425
  } catch (error) {
401
426
  const err = error instanceof Error ? error : new Error(String(error));
402
- emit({event: 'deploy_failed', name: repo.library.name, error: err.message});
427
+ emit({ event: 'deploy_failed', name: repo.library.name, error: err.message });
403
428
  log?.error(st('red', ` ❌ Error deploying ${repo.library.name}: ${err.message}`));
404
429
  }
405
430
  }
@@ -435,8 +460,8 @@ export const execute_publishing_plan = async (
435
460
  log?.info(
436
461
  st(
437
462
  'green',
438
- `\n✨ Dry run complete — ${published.size} package(s) would be published. Re-run with --wetrun to publish.`,
439
- ),
463
+ `\n✨ Dry run complete — ${published.size} package(s) would be published. Re-run with --wetrun to publish.`
464
+ )
440
465
  );
441
466
  // The dry run is driven by the same plan as `gro gitops_plan`, so this
442
467
  // count includes bump escalations and auto-generated changesets — it
@@ -449,22 +474,22 @@ export const execute_publishing_plan = async (
449
474
  'red',
450
475
  wetrun
451
476
  ? '\n❌ Some packages failed to publish\n'
452
- : '\n❌ Some packages failed during dry run\n',
453
- ),
477
+ : '\n❌ Some packages failed during dry run\n'
478
+ )
454
479
  );
455
480
  }
456
481
 
457
- emit({event: 'run_finished', summary});
482
+ emit({ event: 'run_finished', summary });
458
483
 
459
484
  return {
460
485
  ok,
461
486
  published: Array.from(published.values()),
462
- failed: Array.from(failed.entries()).map(([name, error]) => ({name, error})),
487
+ failed: Array.from(failed.entries()).map(([name, error]) => ({ name, error })),
463
488
  duration,
464
489
  events: capture.events,
465
490
  summary,
466
491
  plan_errors: plan.errors,
467
- plan_warnings: plan.warnings,
492
+ plan_warnings: plan.warnings
468
493
  };
469
494
  };
470
495
 
@@ -472,10 +497,13 @@ export const execute_publishing_plan = async (
472
497
  * Publishes a single repo using `gro publish`.
473
498
  *
474
499
  * Dry run mode: reports the precomputed plan entry without side effects.
475
- * Real mode: runs `gro publish --no-build` (builds already validated in preflight),
476
- * reads the new version from `package.json`, and returns it alongside the plan's
477
- * predicted bump metadata. The caller compares the read-back version to the plan to
478
- * detect drift.
500
+ * Real mode: runs `gro publish --no-build --no-pull --branch <entry branch>` (builds
501
+ * already validated in preflight; the readiness re-check just found the branch in sync
502
+ * with origin or ahead of it, so gro's own `git pull` is skipped, and gro checks out the branch the
503
+ * registry entry follows rather than its `main` default) in the foreground — its output
504
+ * live and stdin the terminal's, for npm's one-time-password prompt — reads the new version from
505
+ * `package.json`, and returns it alongside the plan's predicted bump metadata. The
506
+ * caller compares the read-back version to the plan to detect drift.
479
507
  *
480
508
  * @throws {Error} if the publish, version read-back, or commit-hash lookup fails
481
509
  */
@@ -483,9 +511,9 @@ const publish_single_repo = async (
483
511
  repo: LocalRepo,
484
512
  options: PublishingOptions,
485
513
  ops: GitopsOperations,
486
- planned: VersionChange,
514
+ planned: VersionChange
487
515
  ): Promise<PublishedVersion> => {
488
- const {wetrun} = options;
516
+ const { wetrun } = options;
489
517
 
490
518
  if (!wetrun) {
491
519
  // Dry run reports the precomputed plan — the single source of truth for the
@@ -497,24 +525,25 @@ const publish_single_repo = async (
497
525
  bump_type: planned.bump_type,
498
526
  breaking: planned.breaking,
499
527
  commit: 'simulated',
500
- tag: `v${planned.to}`,
528
+ tag: `v${planned.to}`
501
529
  };
502
530
  }
503
531
 
504
- // Run gro publish with --no-build (builds were validated in preflight checks)
505
- const publish_result = await ops.process.spawn({
532
+ const publish_result = await ops.process.run_interactive({
506
533
  cmd: 'gro',
507
- args: ['publish', '--no-build'],
534
+ args: gro_publish_args(repo),
508
535
  cwd: repo.repo_dir,
536
+ stdout: options.child_stdout
509
537
  });
510
538
 
511
539
  if (!publish_result.ok) {
512
- throw new Error(`Failed to publish ${repo.library.name}: ${publish_result.message}`);
540
+ // no "Failed to publish" prefix: the caller's log and report name the package
541
+ throw new Error(format_run_failure(publish_result));
513
542
  }
514
543
 
515
544
  // Read the new version from package.json after gro publish
516
545
  const package_json_path = join(repo.repo_dir, 'package.json');
517
- const content_result = await ops.fs.readFile({path: package_json_path, encoding: 'utf8'});
546
+ const content_result = await ops.fs.readFile({ path: package_json_path, encoding: 'utf8' });
518
547
 
519
548
  if (!content_result.ok) {
520
549
  throw new Error(`Failed to read package.json: ${content_result.message}`);
@@ -524,7 +553,7 @@ const publish_single_repo = async (
524
553
  const new_version = package_json.version;
525
554
 
526
555
  // Get actual commit hash
527
- const commit_result = await ops.git.current_commit_hash({cwd: repo.repo_dir});
556
+ const commit_result = await ops.git.current_commit_hash({ cwd: repo.repo_dir });
528
557
 
529
558
  if (!commit_result.ok) {
530
559
  throw new Error(`Failed to get commit hash: ${commit_result.message}`);
@@ -541,10 +570,63 @@ const publish_single_repo = async (
541
570
  bump_type: planned.bump_type,
542
571
  breaking: planned.breaking,
543
572
  commit,
544
- tag: `v${new_version}`,
573
+ tag: `v${new_version}`
545
574
  };
546
575
  };
547
576
 
577
+ /**
578
+ * Re-checks one repo's readiness right before its `gro publish`: `repos status
579
+ * --fetch --json <key>` through `ops.repos`, then `check_publish_readiness` on
580
+ * that entry — the gate's predicate, so a branch ahead of origin (the
581
+ * executor's own dependency-rewrite commit leaves a dependent ahead) is ready.
582
+ *
583
+ * @returns ok, or the problems found (or why the check couldn't run), joined
584
+ */
585
+ const recheck_publish_readiness = async (
586
+ repo: LocalRepo,
587
+ registry: string | undefined,
588
+ ops: GitopsOperations
589
+ ): Promise<Result<object, { message: string }>> => {
590
+ const key = repo.entry.key;
591
+ const loaded = await load_repos_status({
592
+ keys: [key],
593
+ registry,
594
+ fetch: true,
595
+ repos_ops: ops.repos
596
+ });
597
+ if (!loaded.ok) return { ok: false, message: `the check didn't run: ${loaded.message}` };
598
+ const checked = check_publish_readiness({
599
+ report: loaded.report,
600
+ keys: [key],
601
+ repos_command: to_repos_command(registry)
602
+ });
603
+ return checked.ok ? { ok: true } : { ok: false, message: checked.lines.join('; ') };
604
+ };
605
+
606
+ /**
607
+ * The `gro publish` arguments the executor runs in a repo: `--no-build`, since
608
+ * preflight validated the build; `--no-pull`, since the readiness re-check right
609
+ * before found the branch in sync with origin or ahead of it, so a pull would only
610
+ * move what the check vouched for; and `--branch` naming the branch the registry entry follows,
611
+ * which gro checks out (its default is `main`).
612
+ */
613
+ const gro_publish_args = (repo: Pick<LocalRepo, 'entry'>): Array<string> => {
614
+ const args = ['publish', '--no-build', '--no-pull'];
615
+ if (repo.entry.branch !== null) args.push('--branch', repo.entry.branch);
616
+ return args;
617
+ };
618
+
619
+ /**
620
+ * The failure message for a command the executor ran: why it failed on the first
621
+ * line, then the end of its stderr with known secret shapes redacted, since the
622
+ * message lands in the result, the event stream, and the JSON or markdown report.
623
+ * The live log shows the first line alone, the stderr having just streamed.
624
+ */
625
+ const format_run_failure = (failure: { message: string; stderr_tail?: string }): string =>
626
+ failure.stderr_tail
627
+ ? `${failure.message}\nthe end of its stderr:\n${redact_secrets(failure.stderr_tail)}`
628
+ : failure.message;
629
+
548
630
  /**
549
631
  * Groups dependency updates by dependent package — `dependent → (dependency → new
550
632
  * version)`, the shape `update_package_json` consumes. Restricted by `predicate` (e.g.
@@ -554,7 +636,7 @@ const publish_single_repo = async (
554
636
  export const group_dependency_updates = (
555
637
  updates: Array<DependencyUpdate>,
556
638
  published: Map<string, PublishedVersion>,
557
- predicate: (update: DependencyUpdate) => boolean,
639
+ predicate: (update: DependencyUpdate) => boolean
558
640
  ): Map<string, Map<string, string>> => {
559
641
  const by_repo: Map<string, Map<string, string>> = new Map();
560
642
  for (const update of updates) {
@@ -577,13 +659,13 @@ export const group_dependency_updates = (
577
659
  const dependency_update_type = (
578
660
  plan: PublishingPlan,
579
661
  dependent: string,
580
- dependency: string,
662
+ dependency: string
581
663
  ): 'prod' | 'peer' =>
582
664
  plan.dependency_updates.some(
583
665
  (u) =>
584
666
  u.dependent_package === dependent &&
585
667
  u.updated_dependency === dependency &&
586
- u.type === 'peerDependencies',
668
+ u.type === 'peerDependencies'
587
669
  )
588
670
  ? 'peer'
589
671
  : 'prod';