@fuzdev/fuz_gitops 0.78.1 → 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 (179) hide show
  1. package/README.md +78 -24
  2. package/dist/ModulesDetail.svelte +15 -22
  3. package/dist/ModulesDetail.svelte.d.ts.map +1 -1
  4. package/dist/ModulesNav.svelte +4 -1
  5. package/dist/ModulesPage.svelte +1 -3
  6. package/dist/ModulesPage.svelte.d.ts.map +1 -1
  7. package/dist/PageHeader.svelte +2 -2
  8. package/dist/PageHeader.svelte.d.ts +0 -3
  9. package/dist/PageHeader.svelte.d.ts.map +1 -1
  10. package/dist/PullRequestsDetail.svelte +10 -9
  11. package/dist/PullRequestsDetail.svelte.d.ts.map +1 -1
  12. package/dist/PullRequestsPage.svelte +1 -3
  13. package/dist/PullRequestsPage.svelte.d.ts.map +1 -1
  14. package/dist/ReposTable.svelte +27 -48
  15. package/dist/ReposTable.svelte.d.ts.map +1 -1
  16. package/dist/ReposTree.svelte +5 -19
  17. package/dist/ReposTree.svelte.d.ts.map +1 -1
  18. package/dist/ReposTreeNav.svelte +5 -7
  19. package/dist/ReposTreeNav.svelte.d.ts.map +1 -1
  20. package/dist/TablePage.svelte +1 -3
  21. package/dist/TablePage.svelte.d.ts.map +1 -1
  22. package/dist/TreeItemPage.svelte +1 -3
  23. package/dist/TreeItemPage.svelte.d.ts.map +1 -1
  24. package/dist/TreePage.svelte +1 -3
  25. package/dist/TreePage.svelte.d.ts.map +1 -1
  26. package/dist/ci_reconcile.d.ts +6 -10
  27. package/dist/ci_reconcile.d.ts.map +1 -1
  28. package/dist/ci_reconcile.js +6 -8
  29. package/dist/dependency_graph.d.ts +26 -49
  30. package/dist/dependency_graph.d.ts.map +1 -1
  31. package/dist/dependency_graph.js +37 -80
  32. package/dist/dependency_updater.d.ts +1 -1
  33. package/dist/dependency_updater.d.ts.map +1 -1
  34. package/dist/dependency_updater.js +6 -1
  35. package/dist/fetch_repo_data.d.ts +34 -8
  36. package/dist/fetch_repo_data.d.ts.map +1 -1
  37. package/dist/fetch_repo_data.js +44 -22
  38. package/dist/fs_fetch_value_cache.d.ts +1 -1
  39. package/dist/fs_fetch_value_cache.js +1 -1
  40. package/dist/git_operations.d.ts +6 -49
  41. package/dist/git_operations.d.ts.map +1 -1
  42. package/dist/git_operations.js +13 -126
  43. package/dist/github.d.ts +17 -2
  44. package/dist/github.d.ts.map +1 -1
  45. package/dist/github.js +15 -6
  46. package/dist/github_helpers.d.ts.map +1 -1
  47. package/dist/github_helpers.js +3 -11
  48. package/dist/gitops_analyze.task.d.ts +24 -2
  49. package/dist/gitops_analyze.task.d.ts.map +1 -1
  50. package/dist/gitops_analyze.task.js +43 -30
  51. package/dist/gitops_config.d.ts +34 -79
  52. package/dist/gitops_config.d.ts.map +1 -1
  53. package/dist/gitops_config.js +76 -53
  54. package/dist/gitops_constants.d.ts +6 -4
  55. package/dist/gitops_constants.d.ts.map +1 -1
  56. package/dist/gitops_constants.js +6 -4
  57. package/dist/gitops_plan.task.d.ts +29 -3
  58. package/dist/gitops_plan.task.d.ts.map +1 -1
  59. package/dist/gitops_plan.task.js +80 -58
  60. package/dist/gitops_publish.task.d.ts +62 -2
  61. package/dist/gitops_publish.task.d.ts.map +1 -1
  62. package/dist/gitops_publish.task.js +187 -102
  63. package/dist/gitops_run.task.d.ts +74 -0
  64. package/dist/gitops_run.task.d.ts.map +1 -1
  65. package/dist/gitops_run.task.js +174 -160
  66. package/dist/gitops_sync.task.d.ts +65 -3
  67. package/dist/gitops_sync.task.d.ts.map +1 -1
  68. package/dist/gitops_sync.task.js +154 -30
  69. package/dist/gitops_task_helpers.d.ts +86 -59
  70. package/dist/gitops_task_helpers.d.ts.map +1 -1
  71. package/dist/gitops_task_helpers.js +120 -79
  72. package/dist/gitops_validate.task.d.ts +1 -2
  73. package/dist/gitops_validate.task.d.ts.map +1 -1
  74. package/dist/gitops_validate.task.js +15 -22
  75. package/dist/graph_validation.d.ts +16 -27
  76. package/dist/graph_validation.d.ts.map +1 -1
  77. package/dist/graph_validation.js +23 -64
  78. package/dist/local_repo.d.ts +50 -62
  79. package/dist/local_repo.d.ts.map +1 -1
  80. package/dist/local_repo.js +80 -193
  81. package/dist/log_helpers.d.ts +8 -8
  82. package/dist/log_helpers.d.ts.map +1 -1
  83. package/dist/multi_repo_publisher.d.ts +10 -0
  84. package/dist/multi_repo_publisher.d.ts.map +1 -1
  85. package/dist/multi_repo_publisher.js +115 -51
  86. package/dist/npm_registry.d.ts +23 -19
  87. package/dist/npm_registry.d.ts.map +1 -1
  88. package/dist/npm_registry.js +20 -47
  89. package/dist/operations.d.ts +75 -180
  90. package/dist/operations.d.ts.map +1 -1
  91. package/dist/operations.js +15 -7
  92. package/dist/operations_defaults.d.ts +17 -2
  93. package/dist/operations_defaults.d.ts.map +1 -1
  94. package/dist/operations_defaults.js +104 -121
  95. package/dist/output_helpers.d.ts +36 -0
  96. package/dist/output_helpers.d.ts.map +1 -1
  97. package/dist/output_helpers.js +37 -3
  98. package/dist/preflight_checks.d.ts +20 -27
  99. package/dist/preflight_checks.d.ts.map +1 -1
  100. package/dist/preflight_checks.js +37 -126
  101. package/dist/publish_steps.d.ts +2 -4
  102. package/dist/publish_steps.d.ts.map +1 -1
  103. package/dist/publish_steps.js +2 -6
  104. package/dist/publishing_event.d.ts +2 -8
  105. package/dist/publishing_event.d.ts.map +1 -1
  106. package/dist/publishing_event.js +5 -4
  107. package/dist/publishing_event_handler.d.ts +10 -8
  108. package/dist/publishing_event_handler.d.ts.map +1 -1
  109. package/dist/publishing_event_handler.js +14 -12
  110. package/dist/publishing_plan.d.ts +16 -1
  111. package/dist/publishing_plan.d.ts.map +1 -1
  112. package/dist/publishing_plan.js +328 -250
  113. package/dist/publishing_plan_logging.d.ts +1 -1
  114. package/dist/publishing_plan_logging.d.ts.map +1 -1
  115. package/dist/publishing_plan_logging.js +21 -14
  116. package/dist/repo.svelte.d.ts +7 -0
  117. package/dist/repo.svelte.d.ts.map +1 -1
  118. package/dist/repo.svelte.js +3 -0
  119. package/dist/repo_readiness.d.ts +210 -0
  120. package/dist/repo_readiness.d.ts.map +1 -0
  121. package/dist/repo_readiness.js +516 -0
  122. package/dist/repos_status.d.ts +3340 -0
  123. package/dist/repos_status.d.ts.map +1 -0
  124. package/dist/repos_status.js +555 -0
  125. package/dist/repos_status_load.d.ts +65 -0
  126. package/dist/repos_status_load.d.ts.map +1 -0
  127. package/dist/repos_status_load.js +131 -0
  128. package/package.json +7 -27
  129. package/src/lib/ci_reconcile.ts +7 -11
  130. package/src/lib/dependency_graph.ts +45 -97
  131. package/src/lib/dependency_updater.ts +7 -1
  132. package/src/lib/fetch_repo_data.ts +66 -29
  133. package/src/lib/fs_fetch_value_cache.ts +1 -1
  134. package/src/lib/git_operations.ts +15 -187
  135. package/src/lib/github.ts +20 -8
  136. package/src/lib/github_helpers.ts +5 -12
  137. package/src/lib/gitops_analyze.task.ts +79 -41
  138. package/src/lib/gitops_config.ts +88 -137
  139. package/src/lib/gitops_constants.ts +6 -4
  140. package/src/lib/gitops_plan.task.ts +113 -61
  141. package/src/lib/gitops_publish.task.ts +255 -122
  142. package/src/lib/gitops_run.task.ts +250 -178
  143. package/src/lib/gitops_sync.task.ts +208 -33
  144. package/src/lib/gitops_task_helpers.ts +166 -110
  145. package/src/lib/gitops_validate.task.ts +17 -27
  146. package/src/lib/graph_validation.ts +25 -86
  147. package/src/lib/local_repo.ts +109 -316
  148. package/src/lib/log_helpers.ts +8 -18
  149. package/src/lib/multi_repo_publisher.ts +138 -53
  150. package/src/lib/npm_registry.ts +37 -62
  151. package/src/lib/operations.ts +78 -150
  152. package/src/lib/operations_defaults.ts +108 -154
  153. package/src/lib/output_helpers.ts +55 -3
  154. package/src/lib/preflight_checks.ts +47 -183
  155. package/src/lib/publish_steps.ts +15 -13
  156. package/src/lib/publishing_event.ts +5 -4
  157. package/src/lib/publishing_event_handler.ts +18 -16
  158. package/src/lib/publishing_plan.ts +451 -287
  159. package/src/lib/publishing_plan_logging.ts +32 -23
  160. package/src/lib/repo.svelte.ts +8 -0
  161. package/src/lib/repo_readiness.ts +647 -0
  162. package/src/lib/repos_status.ts +671 -0
  163. package/src/lib/repos_status_load.ts +173 -0
  164. package/dist/config_reconcile.d.ts +0 -46
  165. package/dist/config_reconcile.d.ts.map +0 -1
  166. package/dist/config_reconcile.js +0 -64
  167. package/dist/paths.d.ts +0 -7
  168. package/dist/paths.d.ts.map +0 -1
  169. package/dist/paths.js +0 -6
  170. package/dist/repo_ops.d.ts +0 -59
  171. package/dist/repo_ops.d.ts.map +0 -1
  172. package/dist/repo_ops.js +0 -170
  173. package/dist/resolved_gitops_config.d.ts +0 -9
  174. package/dist/resolved_gitops_config.d.ts.map +0 -1
  175. package/dist/resolved_gitops_config.js +0 -12
  176. package/src/lib/config_reconcile.ts +0 -98
  177. package/src/lib/paths.ts +0 -6
  178. package/src/lib/repo_ops.ts +0 -216
  179. package/src/lib/resolved_gitops_config.ts +0 -27
@@ -11,7 +11,9 @@ import {
11
11
  type DependencyUpdate,
12
12
  type PublishingPlan
13
13
  } from './publishing_plan.ts';
14
- import type { PreflightOptions } from './preflight_checks.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';
15
17
  import type { GitopsOperations } from './operations.ts';
16
18
  import { default_gitops_operations } from './operations_defaults.ts';
17
19
  import { GITOPS_NPM_WAIT_TIMEOUT_DEFAULT } from './gitops_constants.ts';
@@ -23,7 +25,8 @@ import {
23
25
  import {
24
26
  type PublishingEventHandler,
25
27
  capture_handler,
26
- multi_handler
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 {
@@ -89,6 +102,7 @@ export const execute_publishing_plan = async (
89
102
  ): Promise<PublishingResult> => {
90
103
  const start_time = Date.now();
91
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.
@@ -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) {
@@ -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
@@ -236,12 +266,9 @@ export const execute_publishing_plan = async (
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
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}`));
@@ -251,8 +278,8 @@ export const execute_publishing_plan = async (
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,
@@ -281,23 +308,15 @@ export const execute_publishing_plan = async (
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.
331
+ // coarse bucket rather than guessing a cause (auth, build, …) from the message.
313
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
  }
@@ -354,7 +374,7 @@ 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
380
  fs_ops: ops.fs
@@ -381,18 +401,26 @@ export const execute_publishing_plan = async (
381
401
  // Build fresh (no --no-build): a deployed site bundles its dependencies, so it
382
402
  // must be rebuilt against the versions this run just published — the preflight
383
403
  // build ran against the old versions, before the cascade rewrote package.json.
384
- const deploy_result = await ops.process.spawn({
404
+ const deploy_result = await ops.process.run_interactive({
385
405
  cmd: 'gro',
386
406
  args: ['deploy'],
387
- cwd: repo.repo_dir
407
+ cwd: repo.repo_dir,
408
+ stdout: options.child_stdout
388
409
  });
389
410
 
390
411
  if (deploy_result.ok) {
391
412
  emit({ event: 'deploy_completed', name: repo.library.name });
392
413
  log?.info(st('green', ` ✅ Deployed ${repo.library.name}`));
393
414
  } else {
394
- emit({ event: 'deploy_failed', name: repo.library.name, error: deploy_result.message });
395
- 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
+ );
396
424
  }
397
425
  } catch (error) {
398
426
  const err = error instanceof Error ? error : new Error(String(error));
@@ -469,10 +497,13 @@ export const execute_publishing_plan = async (
469
497
  * Publishes a single repo using `gro publish`.
470
498
  *
471
499
  * Dry run mode: reports the precomputed plan entry without side effects.
472
- * Real mode: runs `gro publish --no-build` (builds already validated in preflight),
473
- * reads the new version from `package.json`, and returns it alongside the plan's
474
- * predicted bump metadata. The caller compares the read-back version to the plan to
475
- * 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.
476
507
  *
477
508
  * @throws {Error} if the publish, version read-back, or commit-hash lookup fails
478
509
  */
@@ -498,15 +529,16 @@ const publish_single_repo = async (
498
529
  };
499
530
  }
500
531
 
501
- // Run gro publish with --no-build (builds were validated in preflight checks)
502
- const publish_result = await ops.process.spawn({
532
+ const publish_result = await ops.process.run_interactive({
503
533
  cmd: 'gro',
504
- args: ['publish', '--no-build'],
505
- cwd: repo.repo_dir
534
+ args: gro_publish_args(repo),
535
+ cwd: repo.repo_dir,
536
+ stdout: options.child_stdout
506
537
  });
507
538
 
508
539
  if (!publish_result.ok) {
509
- 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));
510
542
  }
511
543
 
512
544
  // Read the new version from package.json after gro publish
@@ -542,6 +574,59 @@ const publish_single_repo = async (
542
574
  };
543
575
  };
544
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
+
545
630
  /**
546
631
  * Groups dependency updates by dependent package — `dependent → (dependency → new
547
632
  * version)`, the shape `update_package_json` consumes. Restricted by `predicate` (e.g.
@@ -11,28 +11,44 @@ export interface WaitOptions {
11
11
  timeout?: number;
12
12
  }
13
13
 
14
- export interface PackageInfo {
15
- name: string;
16
- version: string;
14
+ /**
15
+ * The side effects of the registry checks — running npm, sleeping, and reading
16
+ * the clock — injected so tests drive them with plain objects.
17
+ */
18
+ export interface NpmRegistryDeps {
19
+ /**
20
+ * Runs `npm` with `args` and resolves to what it wrote to stdout — empty when
21
+ * nothing, or when npm couldn't run. A throw counts as unavailable too.
22
+ */
23
+ run_npm: (args: Array<string>) => Promise<string>;
24
+ /** Sleeps for `ms` milliseconds. */
25
+ wait: (ms: number) => Promise<void>;
26
+ /** The current time in milliseconds, as `Date.now`. */
27
+ now: () => number;
17
28
  }
18
29
 
30
+ export const default_npm_registry_deps: NpmRegistryDeps = {
31
+ run_npm: async (args) => (await spawn_out('npm', args)).stdout ?? '',
32
+ wait,
33
+ now: () => Date.now()
34
+ };
35
+
36
+ /**
37
+ * Checks whether `pkg@version` is on the npm registry, by `npm view`.
38
+ *
39
+ * @returns `true` when the registry reports that exact version, `false` when it
40
+ * doesn't or npm couldn't run
41
+ */
19
42
  export const check_package_available = async (
20
43
  pkg: string,
21
44
  version: string,
22
- options: { log?: Logger } = {}
45
+ options: { log?: Logger } = {},
46
+ deps: NpmRegistryDeps = default_npm_registry_deps
23
47
  ): Promise<boolean> => {
24
48
  const { log } = options;
25
49
  try {
26
- // Use npm view to check if the specific version exists
27
- const result = await spawn_out('npm', ['view', `${pkg}@${version}`, 'version']);
28
-
29
- if (result.stdout) {
30
- const output = result.stdout.trim();
31
- // If we get a version back, it exists
32
- return output === version;
33
- }
34
-
35
- return false;
50
+ const stdout = await deps.run_npm(['view', `${pkg}@${version}`, 'version']);
51
+ return stdout.trim() === version;
36
52
  } catch (error) {
37
53
  log?.debug(`Failed to check ${pkg}@${version}: ${error}`);
38
54
  return false;
@@ -57,7 +73,8 @@ export const check_package_available = async (
57
73
  export const wait_for_package = async (
58
74
  pkg: string,
59
75
  version: string,
60
- options: WaitOptions = {}
76
+ options: WaitOptions = {},
77
+ deps: NpmRegistryDeps = default_npm_registry_deps
61
78
  ): Promise<void> => {
62
79
  const {
63
80
  log,
@@ -67,34 +84,31 @@ export const wait_for_package = async (
67
84
  timeout = 300000 // 5 minutes default
68
85
  } = options;
69
86
 
70
- const start_time = Date.now();
87
+ const start_time = deps.now();
71
88
  let attempt = 0;
72
89
  let delay = initial_delay;
73
90
 
74
91
  while (attempt < max_attempts) {
75
92
  attempt++;
76
93
 
77
- // Check timeout
78
- if (Date.now() - start_time > timeout) {
94
+ if (deps.now() - start_time > timeout) {
79
95
  throw new Error(`Timeout waiting for ${pkg}@${version} after ${timeout}ms`);
80
96
  }
81
97
 
82
- // Check if package is available
83
-
84
- if (await check_package_available(pkg, version, { log })) {
98
+ if (await check_package_available(pkg, version, { log }, deps)) {
85
99
  log?.info(st('green', ` ✓ ${pkg}@${version} is now available on NPM`));
86
100
  return;
87
101
  }
88
102
 
89
103
  // Log progress occasionally
90
- if (attempt > 0 && attempt % 5 === 0) {
104
+ if (attempt % 5 === 0) {
91
105
  log?.info(st('dim', ` Still waiting... (attempt ${attempt}/${max_attempts})`));
92
106
  }
93
107
 
94
108
  // Wait with exponential backoff + jitter
95
109
  const jitter = Math.random() * delay * 0.1; // 10% jitter
96
110
  const actual_delay = Math.min(delay + jitter, max_delay);
97
- await wait(actual_delay);
111
+ await deps.wait(actual_delay);
98
112
 
99
113
  // Exponential backoff
100
114
  delay = Math.min(delay * 1.5, max_delay);
@@ -102,42 +116,3 @@ export const wait_for_package = async (
102
116
 
103
117
  throw new Error(`${pkg}@${version} not available after ${max_attempts} attempts`);
104
118
  };
105
-
106
- /**
107
- * Fetches package metadata from NPM registry.
108
- *
109
- * Returns name and latest version. Returns null if package doesn't exist
110
- * or registry is unreachable.
111
- *
112
- * @returns package info or null on error/not found
113
- */
114
- export const get_package_info = async (
115
- pkg: string,
116
- options: { log?: Logger } = {}
117
- ): Promise<PackageInfo | null> => {
118
- const { log } = options;
119
- try {
120
- const result = await spawn_out('npm', ['view', pkg, '--json']);
121
-
122
- if (result.stdout) {
123
- const data = JSON.parse(result.stdout);
124
- return {
125
- name: data.name,
126
- version: data.version
127
- };
128
- }
129
-
130
- return null;
131
- } catch (error) {
132
- log?.debug(`Failed to get package info for ${pkg}: ${error}`);
133
- return null;
134
- }
135
- };
136
-
137
- export const package_exists = async (
138
- pkg: string,
139
- options: { log?: Logger } = {}
140
- ): Promise<boolean> => {
141
- const info = await get_package_info(pkg, options);
142
- return info !== null;
143
- };