@fuzdev/fuz_gitops 0.71.0 → 0.73.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. package/README.md +13 -6
  2. package/dist/changeset_generator.d.ts.map +1 -1
  3. package/dist/changeset_generator.js +2 -16
  4. package/dist/changeset_reader.d.ts +1 -1
  5. package/dist/changeset_reader.d.ts.map +1 -1
  6. package/dist/dependency_graph.js +1 -1
  7. package/dist/git_operations.d.ts +5 -0
  8. package/dist/git_operations.d.ts.map +1 -1
  9. package/dist/git_operations.js +5 -0
  10. package/dist/gitops_analyze.task.d.ts +1 -0
  11. package/dist/gitops_analyze.task.d.ts.map +1 -1
  12. package/dist/gitops_analyze.task.js +12 -17
  13. package/dist/gitops_plan.task.d.ts +1 -0
  14. package/dist/gitops_plan.task.d.ts.map +1 -1
  15. package/dist/gitops_plan.task.js +9 -2
  16. package/dist/gitops_publish.task.d.ts +3 -2
  17. package/dist/gitops_publish.task.d.ts.map +1 -1
  18. package/dist/gitops_publish.task.js +90 -26
  19. package/dist/gitops_run.task.d.ts +2 -1
  20. package/dist/gitops_run.task.d.ts.map +1 -1
  21. package/dist/gitops_run.task.js +40 -7
  22. package/dist/gitops_sync.task.d.ts +1 -0
  23. package/dist/gitops_sync.task.d.ts.map +1 -1
  24. package/dist/gitops_sync.task.js +16 -2
  25. package/dist/gitops_task_helpers.d.ts +16 -2
  26. package/dist/gitops_task_helpers.d.ts.map +1 -1
  27. package/dist/gitops_task_helpers.js +11 -3
  28. package/dist/gitops_validate.task.d.ts +1 -0
  29. package/dist/gitops_validate.task.d.ts.map +1 -1
  30. package/dist/gitops_validate.task.js +20 -20
  31. package/dist/graph_validation.d.ts +16 -1
  32. package/dist/graph_validation.d.ts.map +1 -1
  33. package/dist/graph_validation.js +15 -0
  34. package/dist/local_repo.d.ts +22 -10
  35. package/dist/local_repo.d.ts.map +1 -1
  36. package/dist/local_repo.js +92 -75
  37. package/dist/log_helpers.d.ts +0 -5
  38. package/dist/log_helpers.d.ts.map +1 -1
  39. package/dist/log_helpers.js +8 -19
  40. package/dist/multi_repo_publisher.d.ts +29 -2
  41. package/dist/multi_repo_publisher.d.ts.map +1 -1
  42. package/dist/multi_repo_publisher.js +307 -233
  43. package/dist/operations.d.ts +6 -26
  44. package/dist/operations.d.ts.map +1 -1
  45. package/dist/operations_defaults.d.ts.map +1 -1
  46. package/dist/operations_defaults.js +1 -19
  47. package/dist/output_helpers.d.ts.map +1 -1
  48. package/dist/output_helpers.js +7 -6
  49. package/dist/paths.d.ts +0 -4
  50. package/dist/paths.d.ts.map +1 -1
  51. package/dist/paths.js +0 -4
  52. package/dist/publish_gate.d.ts +44 -0
  53. package/dist/publish_gate.d.ts.map +1 -0
  54. package/dist/publish_gate.js +32 -0
  55. package/dist/publish_steps.d.ts +60 -0
  56. package/dist/publish_steps.d.ts.map +1 -0
  57. package/dist/publish_steps.js +113 -0
  58. package/dist/publishing_event.d.ts +123 -0
  59. package/dist/publishing_event.d.ts.map +1 -0
  60. package/dist/publishing_event.js +140 -0
  61. package/dist/publishing_event_handler.d.ts +42 -0
  62. package/dist/publishing_event_handler.d.ts.map +1 -0
  63. package/dist/publishing_event_handler.js +75 -0
  64. package/dist/publishing_plan.d.ts +1 -2
  65. package/dist/publishing_plan.d.ts.map +1 -1
  66. package/dist/publishing_plan.js +18 -1
  67. package/dist/publishing_plan_helpers.d.ts +1 -1
  68. package/dist/publishing_plan_helpers.d.ts.map +1 -1
  69. package/dist/publishing_plan_helpers.js +2 -15
  70. package/dist/publishing_plan_logging.d.ts.map +1 -1
  71. package/dist/publishing_plan_logging.js +15 -23
  72. package/dist/repo_ops.d.ts +1 -1
  73. package/dist/repo_ops.js +1 -1
  74. package/dist/version_utils.d.ts +17 -3
  75. package/dist/version_utils.d.ts.map +1 -1
  76. package/dist/version_utils.js +22 -0
  77. package/package.json +6 -7
  78. package/src/lib/changeset_generator.ts +10 -19
  79. package/src/lib/changeset_reader.ts +1 -2
  80. package/src/lib/dependency_graph.ts +1 -1
  81. package/src/lib/git_operations.ts +5 -0
  82. package/src/lib/gitops_analyze.task.ts +18 -23
  83. package/src/lib/gitops_plan.task.ts +10 -2
  84. package/src/lib/gitops_publish.task.ts +100 -25
  85. package/src/lib/gitops_run.task.ts +39 -7
  86. package/src/lib/gitops_sync.task.ts +18 -3
  87. package/src/lib/gitops_task_helpers.ts +20 -3
  88. package/src/lib/gitops_validate.task.ts +30 -22
  89. package/src/lib/graph_validation.ts +26 -0
  90. package/src/lib/local_repo.ts +114 -83
  91. package/src/lib/log_helpers.ts +8 -26
  92. package/src/lib/multi_repo_publisher.ts +387 -269
  93. package/src/lib/operations.ts +6 -22
  94. package/src/lib/operations_defaults.ts +1 -19
  95. package/src/lib/output_helpers.ts +7 -6
  96. package/src/lib/paths.ts +0 -5
  97. package/src/lib/publish_gate.ts +53 -0
  98. package/src/lib/publish_steps.ts +149 -0
  99. package/src/lib/publishing_event.ts +151 -0
  100. package/src/lib/publishing_event_handler.ts +98 -0
  101. package/src/lib/publishing_plan.ts +23 -3
  102. package/src/lib/publishing_plan_helpers.ts +5 -18
  103. package/src/lib/publishing_plan_logging.ts +19 -39
  104. package/src/lib/repo_ops.ts +1 -1
  105. package/src/lib/version_utils.ts +30 -9
  106. package/dist/npm_install_helpers.d.ts +0 -23
  107. package/dist/npm_install_helpers.d.ts.map +0 -1
  108. package/dist/npm_install_helpers.js +0 -60
  109. package/dist/semver.d.ts +0 -26
  110. package/dist/semver.d.ts.map +0 -1
  111. package/dist/semver.js +0 -137
  112. package/dist/serialization_types.d.ts +0 -59
  113. package/dist/serialization_types.d.ts.map +0 -1
  114. package/dist/serialization_types.js +0 -42
  115. package/src/lib/npm_install_helpers.ts +0 -85
  116. package/src/lib/semver.ts +0 -170
  117. package/src/lib/serialization_types.ts +0 -92
@@ -39,7 +39,7 @@ import type {FsError} from '@fuzdev/fuz_util/fs.js';
39
39
  import type {Logger} from '@fuzdev/fuz_util/log.js';
40
40
  import type {LocalRepo} from './local_repo.js';
41
41
  import type {ChangesetInfo} from './changeset_reader.js';
42
- import type {BumpType} from './semver.js';
42
+ import type {BumpType} from './version_utils.js';
43
43
  import type {PreflightOptions, PreflightResult} from './preflight_checks.js';
44
44
  import type {WaitOptions} from './npm_registry.js';
45
45
 
@@ -156,16 +156,15 @@ export interface GitOperations {
156
156
  }) => Promise<Result<object, {message: string}>>;
157
157
 
158
158
  /**
159
- * Checks if there are any uncommitted changes.
159
+ * Checks whether the working tree has any changes — staged, unstaged, or
160
+ * untracked (`git status --porcelain`). Broader than `list_uncommitted_files`,
161
+ * which reports only tracked working-tree changes relative to HEAD.
160
162
  */
161
163
  has_changes: (options?: {cwd?: string}) => Promise<Result<{value: boolean}, {message: string}>>;
162
164
 
163
165
  /**
164
- * Lists uncommitted files in the working tree (`git diff --name-only HEAD`).
165
- *
166
- * Renamed from `get_changed_files` in 2026-04 because "changed files" collided
167
- * with mageguild's `get_changed_files` which diffs two refs. This one reports
168
- * uncommitted working-tree changes relative to HEAD.
166
+ * Lists uncommitted files in the working tree (`git diff --name-only HEAD`),
167
+ * i.e. working-tree changes relative to HEAD (not a diff between two refs).
169
168
  */
170
169
  list_uncommitted_files: (options?: {
171
170
  cwd?: string;
@@ -253,15 +252,6 @@ export interface NpmOperations {
253
252
  log?: Logger;
254
253
  }) => Promise<Result<object, {message: string; timeout?: boolean}>>;
255
254
 
256
- /**
257
- * Checks if a package version is available on NPM.
258
- */
259
- check_package_available: (options: {
260
- pkg: string;
261
- version: string;
262
- log?: Logger;
263
- }) => Promise<Result<{value: boolean}, {message: string}>>;
264
-
265
255
  /**
266
256
  * Checks npm authentication status.
267
257
  */
@@ -278,12 +268,6 @@ export interface NpmOperations {
278
268
  install: (options?: {
279
269
  cwd?: string;
280
270
  }) => Promise<Result<object, {message: string; stderr?: string}>>;
281
-
282
- /**
283
- * Cleans the npm cache.
284
- * Uses `npm cache clean --force` to clear stale cache entries.
285
- */
286
- cache_clean: () => Promise<Result<object, {message: string}>>;
287
271
  }
288
272
 
289
273
  /**
@@ -14,7 +14,7 @@ import {fs_classify_error} from '@fuzdev/fuz_util/fs.js';
14
14
  import {EMPTY_OBJECT} from '@fuzdev/fuz_util/object.js';
15
15
 
16
16
  import {has_changesets, read_changesets, predict_next_version} from './changeset_reader.js';
17
- import {wait_for_package, check_package_available} from './npm_registry.js';
17
+ import {wait_for_package} from './npm_registry.js';
18
18
  import {run_preflight_checks} from './preflight_checks.js';
19
19
  import {
20
20
  git_add,
@@ -236,11 +236,6 @@ export const default_npm_operations: NpmOperations = {
236
236
  }
237
237
  },
238
238
 
239
- check_package_available: async (options) => {
240
- const {pkg, version, log} = options;
241
- return wrap_with_value(() => check_package_available(pkg, version, {log}));
242
- },
243
-
244
239
  check_auth: async () => {
245
240
  try {
246
241
  const result = await spawn_out('npm', ['whoami']);
@@ -281,19 +276,6 @@ export const default_npm_operations: NpmOperations = {
281
276
  return {ok: false, message: String(error)};
282
277
  }
283
278
  },
284
-
285
- cache_clean: async () => {
286
- try {
287
- const spawned = await spawn_out('npm', ['cache', 'clean', '--force']);
288
- if (spawned.result.ok) {
289
- return {ok: true};
290
- } else {
291
- return {ok: false, message: 'Cache clean failed'};
292
- }
293
- } catch (error) {
294
- return {ok: false, message: String(error)};
295
- }
296
- },
297
279
  };
298
280
 
299
281
  export const default_preflight_operations: PreflightOperations = {
@@ -50,15 +50,16 @@ export const format_and_output = async <T>(
50
50
  // Format data
51
51
  const content = format === 'json' ? formatters.json(data) : formatters.markdown(data).join('\n');
52
52
 
53
- // Output to file or log
53
+ // Output to file or stdout
54
54
  if (outfile) {
55
55
  await writeFile(outfile, content);
56
56
  log?.info(`Output written to ${outfile}`);
57
57
  } else {
58
- // Log line by line for better formatting
59
- const lines = content.split('\n');
60
- for (const line of lines) {
61
- log?.info(line);
62
- }
58
+ // Write raw to stdout in one shot. Routing through `log` would prefix every
59
+ // line with `[taskname]`, corrupting JSON and cluttering markdown — keep the
60
+ // machine-readable formats unprefixed and pipeable (use `--outfile` for output
61
+ // fully free of Gro's own preamble).
62
+ // eslint-disable-next-line no-console
63
+ console.log(content);
63
64
  }
64
65
  };
package/src/lib/paths.ts CHANGED
@@ -1,8 +1,3 @@
1
- /**
2
- * Base directory for all gitops-generated files.
3
- */
4
- export const GITOPS_OUTPUT_DIR = '.gro/fuz_gitops';
5
-
6
1
  /**
7
2
  * Default repos directory relative to gitops config file.
8
3
  * Resolves to the parent of the directory with the config
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Pure decision logic for the `gitops_publish` task's interactive gating.
3
+ *
4
+ * Extracted from `gitops_publish.task.ts` so the gating table is unit-testable without driving
5
+ * readline or `process.exit`. The task keeps the IO — prompt, exit, logging — at its edge and
6
+ * consults these for every branch.
7
+ *
8
+ * @module
9
+ */
10
+
11
+ import type {PublishingResult} from './multi_repo_publisher.js';
12
+ import type {PublishingPlan} from './publishing_plan.js';
13
+
14
+ /** What the task should do with a generated plan before executing the cascade. */
15
+ export type PublishGate =
16
+ | {action: 'blocked'; message: string}
17
+ | {action: 'confirm'}
18
+ | {action: 'proceed'};
19
+
20
+ export interface PublishGateOptions {
21
+ /** A real publish (`--wetrun`); a dry run never prompts. */
22
+ wetrun: boolean;
23
+ /** Show the plan and confirm (`plan`); `--no-plan` skips the prompt. */
24
+ show_plan: boolean;
25
+ plan: Pick<PublishingPlan, 'errors'>;
26
+ }
27
+
28
+ /**
29
+ * Decides whether a publish run must block, prompt for confirmation, or proceed without
30
+ * prompting, from the pre-execution inputs.
31
+ *
32
+ * - `blocked`: a real publish whose plan has errors — fail loud before prompting. The executor
33
+ * enforces this too, so `--no-plan` can't bypass the gate; this branch only avoids prompting
34
+ * for (and printing the "this will publish" banner of) a plan that can't run.
35
+ * - `confirm`: a real publish that shows its plan — the user must confirm interactively.
36
+ * - `proceed`: a dry run, or a `--no-plan` real publish — no prompt.
37
+ */
38
+ export const decide_publish_gate = (options: PublishGateOptions): PublishGate => {
39
+ if (!options.wetrun || !options.show_plan) return {action: 'proceed'};
40
+ if (options.plan.errors.length > 0) {
41
+ return {action: 'blocked', message: 'Cannot proceed with publishing due to errors'};
42
+ }
43
+ return {action: 'confirm'};
44
+ };
45
+
46
+ /**
47
+ * Whether a finished run should exit non-zero: an unsuccessful result, or a fatal error thrown
48
+ * out of the executor.
49
+ */
50
+ export const publish_run_failed = (
51
+ result: Pick<PublishingResult, 'ok'>,
52
+ fatal_error: Error | null,
53
+ ): boolean => !result.ok || fatal_error !== null;
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Side-effect preview for a publishing plan.
3
+ *
4
+ * `derive_publish_steps` linearizes a frozen `PublishingPlan` into the ordered side-effects
5
+ * a `--wetrun` would perform, mirroring `execute_publishing_plan`'s pass so the preview and
6
+ * the executor read the same plan data and can't drift. Pure — no side effects.
7
+ *
8
+ * @module
9
+ */
10
+
11
+ import type {BumpType} from './version_utils.js';
12
+ import type {PublishingPlan, VersionChange} from './publishing_plan.js';
13
+ import {UnreachableError} from '@fuzdev/fuz_util/error.js';
14
+
15
+ /** How a package's version bump arises in the plan. */
16
+ export type PublishStepVia = 'changeset' | 'auto_changeset' | 'escalation';
17
+
18
+ /** One ordered side-effect a wetrun would perform. */
19
+ export type PublishStep =
20
+ | {kind: 'publish'; repo: string; from: string; to: string; bump: BumpType; via: PublishStepVia}
21
+ | {kind: 'npm_wait'; repo: string; version: string}
22
+ | {
23
+ kind: 'dependency_update';
24
+ dependent: string;
25
+ dependency: string;
26
+ to: string;
27
+ dep_type: 'prod' | 'peer';
28
+ creates_changeset: boolean;
29
+ }
30
+ | {kind: 'dev_dep_update'; repo: string; dependency: string; to: string}
31
+ | {kind: 'deploy'; repo: string; builds: boolean};
32
+
33
+ export interface DerivePublishStepsOptions {
34
+ /** Include the deploy phase (the publisher only deploys with `--deploy`). */
35
+ deploy?: boolean;
36
+ }
37
+
38
+ const step_via = (change: VersionChange): PublishStepVia =>
39
+ change.needs_bump_escalation
40
+ ? 'escalation'
41
+ : change.has_changesets
42
+ ? 'changeset'
43
+ : 'auto_changeset';
44
+
45
+ /**
46
+ * Derives the ordered side-effects a wetrun would perform from a frozen plan.
47
+ *
48
+ * Reads only `publishing_order`, `version_changes`, and `dependency_updates` — the same data
49
+ * `execute_publishing_plan` consumes, in the same order — so the preview reflects the real
50
+ * pass. A dependency is only propagated to its dependents if it actually publishes this run.
51
+ */
52
+ export const derive_publish_steps = (
53
+ plan: PublishingPlan,
54
+ options: DerivePublishStepsOptions = {},
55
+ ): Array<PublishStep> => {
56
+ const {deploy = false} = options;
57
+ const changes: Map<string, VersionChange> = new Map(
58
+ plan.version_changes.map((vc) => [vc.package_name, vc]),
59
+ );
60
+
61
+ const steps: Array<PublishStep> = [];
62
+ const changed: Set<string> = new Set(); // for the deploy phase: published + their dependents
63
+
64
+ // Phase 1: one pass over the topological order, mirroring `execute_publishing_plan`.
65
+ for (const repo of plan.publishing_order) {
66
+ const change = changes.get(repo);
67
+ if (!change) continue; // not in the plan = nothing to publish
68
+
69
+ steps.push({
70
+ kind: 'publish',
71
+ repo,
72
+ from: change.from,
73
+ to: change.to,
74
+ bump: change.bump_type,
75
+ via: step_via(change),
76
+ });
77
+ steps.push({kind: 'npm_wait', repo, version: change.to});
78
+ changed.add(repo);
79
+
80
+ // Prod/peer dependents are rewritten right after this publishes. A dependent that
81
+ // republishes (has its own plan version change) gets an auto-changeset and republishes in
82
+ // turn (its rewritten deps are installed + healed by its own `gro publish`); a private
83
+ // dependent has no version change, so it's an update-only leaf — range rewritten, no
84
+ // changeset.
85
+ for (const update of plan.dependency_updates) {
86
+ if (update.updated_dependency !== repo) continue;
87
+ if (update.type !== 'dependencies' && update.type !== 'peerDependencies') continue;
88
+ const republishes = changes.has(update.dependent_package);
89
+ steps.push({
90
+ kind: 'dependency_update',
91
+ dependent: update.dependent_package,
92
+ dependency: repo,
93
+ to: change.to,
94
+ dep_type: update.type === 'peerDependencies' ? 'peer' : 'prod',
95
+ creates_changeset: republishes,
96
+ });
97
+ changed.add(update.dependent_package);
98
+ }
99
+ }
100
+
101
+ // Phase 2: dev-dependency updates for deps that published this run — committed without a
102
+ // changeset (dev-only changes redeploy, they don't republish). No install step: these repos
103
+ // don't run `gro publish`; gro installs + heals their deps when they next build/deploy/sync.
104
+ for (const update of plan.dependency_updates) {
105
+ if (update.type !== 'devDependencies') continue;
106
+ const dep_change = changes.get(update.updated_dependency);
107
+ if (!dep_change) continue;
108
+ steps.push({
109
+ kind: 'dev_dep_update',
110
+ repo: update.dependent_package,
111
+ dependency: update.updated_dependency,
112
+ to: dep_change.to,
113
+ });
114
+ changed.add(update.dependent_package);
115
+ }
116
+
117
+ // Phase 3: deploy every changed repo (only with --deploy). Each builds fresh.
118
+ if (deploy) {
119
+ for (const repo of changed) {
120
+ steps.push({kind: 'deploy', repo, builds: true});
121
+ }
122
+ }
123
+
124
+ return steps;
125
+ };
126
+
127
+ /**
128
+ * Formats steps as human-readable lines (one per step) for stdout and markdown output.
129
+ * Returns a single placeholder line when there are no side effects.
130
+ */
131
+ export const format_publish_steps = (steps: Array<PublishStep>): Array<string> => {
132
+ if (steps.length === 0) return ['(no side effects — nothing to publish)'];
133
+ return steps.map((step) => {
134
+ switch (step.kind) {
135
+ case 'publish':
136
+ return `publish ${step.repo} ${step.from} → ${step.to} (${step.bump}, ${step.via})`;
137
+ case 'npm_wait':
138
+ return `npm wait ${step.repo}@${step.version}`;
139
+ case 'dependency_update':
140
+ return `update ${step.dependent} ← ${step.dependency}@${step.to} (${step.dep_type}${step.creates_changeset ? ', changeset' : ''})`;
141
+ case 'dev_dep_update':
142
+ return `dev dep ${step.repo} ← ${step.dependency}@${step.to} (no changeset)`;
143
+ case 'deploy':
144
+ return `deploy ${step.repo}${step.builds ? ' (builds)' : ''}`;
145
+ default:
146
+ throw new UnreachableError(step);
147
+ }
148
+ });
149
+ };
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Structured events for multi-repo publishing.
3
+ *
4
+ * Publishing emits a stream of tagged events alongside its human-readable logging,
5
+ * so machine consumers (CI, dashboards) can follow a run step by step. Every run
6
+ * opens with a `run_started` event carrying `wetrun`: when `false`, the run is a dry
7
+ * run and every `package_completed` is a prediction (its `commit` is `'simulated'`)
8
+ * rather than an applied change. A run's `run_finished` summary is derived from the
9
+ * same event list via `summarize_events`, so the stream and the summary never drift.
10
+ *
11
+ * Events are consumed through a `PublishingEventHandler` sink (see
12
+ * `publishing_event_handler.ts`).
13
+ *
14
+ * @module
15
+ */
16
+
17
+ import {z} from 'zod';
18
+
19
+ /**
20
+ * Coarse triage classification for a failed package. Lets consumers branch on
21
+ * failure kind without parsing the message.
22
+ */
23
+ export const PublishingErrorCode = z.enum([
24
+ 'publish',
25
+ 'network',
26
+ 'auth',
27
+ 'dependency',
28
+ 'build',
29
+ // the real published version diverged from the frozen plan's prediction — an
30
+ // invariant violation, distinct from an ordinary publish failure (see fail-loud
31
+ // drift detection in `multi_repo_publisher.ts`)
32
+ 'drift',
33
+ 'other',
34
+ ]);
35
+ export type PublishingErrorCode = z.infer<typeof PublishingErrorCode>;
36
+
37
+ /** Tallied outcome of a publishing run, derived from its events via `summarize_events`. */
38
+ export const PublishingRunSummary = z.strictObject({
39
+ total: z.number().meta({description: 'packages in the publishing order (the candidate set)'}),
40
+ published: z.number().meta({description: 'packages published (or, in a dry run, predicted)'}),
41
+ failed: z.number(),
42
+ skipped: z.number(),
43
+ duration: z.number().meta({description: 'wall-clock duration in milliseconds'}),
44
+ });
45
+ export type PublishingRunSummary = z.infer<typeof PublishingRunSummary>;
46
+
47
+ /**
48
+ * A single structured event emitted during a publishing run. Tagged on `event` so the
49
+ * union serializes as one self-describing JSON object per event (JSON-lines on the wire).
50
+ */
51
+ export const PublishingEvent = z.discriminatedUnion('event', [
52
+ z.strictObject({
53
+ event: z.literal('run_started'),
54
+ wetrun: z.boolean().meta({description: 'false means every package_completed is a prediction'}),
55
+ total: z.number(),
56
+ }),
57
+ z.strictObject({
58
+ event: z.literal('package_skipped'),
59
+ name: z.string(),
60
+ reason: z.string(),
61
+ }),
62
+ z.strictObject({
63
+ event: z.literal('package_completed'),
64
+ name: z.string(),
65
+ old_version: z.string(),
66
+ new_version: z.string(),
67
+ // mirrors `BumpType` from `version_utils.ts`; inline so the event schema is self-contained
68
+ bump_type: z.enum(['major', 'minor', 'patch']),
69
+ breaking: z.boolean(),
70
+ commit: z.string().meta({description: "'simulated' in a dry run, otherwise the commit hash"}),
71
+ tag: z.string(),
72
+ }),
73
+ z.strictObject({
74
+ event: z.literal('npm_waited'),
75
+ name: z.string(),
76
+ version: z.string().meta({description: 'the version waited on after publishing'}),
77
+ }),
78
+ z.strictObject({
79
+ event: z.literal('package_failed'),
80
+ name: z.string(),
81
+ error: z.string(),
82
+ code: PublishingErrorCode,
83
+ }),
84
+ z.strictObject({
85
+ event: z.literal('dependency_updated'),
86
+ dependent: z.string(),
87
+ dependency: z.string(),
88
+ version: z.string(),
89
+ // 'prod'/'peer' updates run inline in the publish pass; 'dev' updates run in the later
90
+ // dev-dependency pass. Lets a consumer reconstruct which side-effect phase this is.
91
+ dep_type: z.enum(['prod', 'peer', 'dev']),
92
+ // true when the update creates an auto-changeset (a publishable dependent republishes);
93
+ // false for dev-dep updates and update-only leaves (private dependents never publish).
94
+ creates_changeset: z.boolean(),
95
+ }),
96
+ z.strictObject({
97
+ event: z.literal('deploy_started'),
98
+ name: z.string(),
99
+ }),
100
+ z.strictObject({
101
+ event: z.literal('deploy_completed'),
102
+ name: z.string(),
103
+ }),
104
+ z.strictObject({
105
+ event: z.literal('deploy_failed'),
106
+ name: z.string(),
107
+ error: z.string(),
108
+ }),
109
+ z.strictObject({
110
+ event: z.literal('run_finished'),
111
+ summary: PublishingRunSummary,
112
+ }),
113
+ ]);
114
+ export type PublishingEvent = z.infer<typeof PublishingEvent>;
115
+
116
+ /**
117
+ * Derives a run summary from the captured event list — the single canonical path from
118
+ * events to summary, so the `run_finished` summary always agrees with the stream.
119
+ * Call before emitting `run_finished` (which is not itself counted).
120
+ *
121
+ * @param events - the events captured so far this run
122
+ * @param duration - wall-clock duration in milliseconds
123
+ */
124
+ export const summarize_events = (
125
+ events: Array<PublishingEvent>,
126
+ duration: number,
127
+ ): PublishingRunSummary => {
128
+ let total = 0;
129
+ let published = 0;
130
+ let failed = 0;
131
+ let skipped = 0;
132
+ for (const event of events) {
133
+ switch (event.event) {
134
+ case 'run_started':
135
+ total = event.total;
136
+ break;
137
+ case 'package_completed':
138
+ published++;
139
+ break;
140
+ case 'package_failed':
141
+ failed++;
142
+ break;
143
+ case 'package_skipped':
144
+ skipped++;
145
+ break;
146
+ default:
147
+ break;
148
+ }
149
+ }
150
+ return {total, published, failed, skipped, duration};
151
+ };
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Composable sinks for the publishing event stream.
3
+ *
4
+ * A `PublishingEventHandler` is anything that can receive a `PublishingEvent`. Handlers
5
+ * compose: `multi_handler` fans out, `masking_handler` redacts secrets then forwards.
6
+ * Emission is best-effort and synchronous — an observability sink must never fail or
7
+ * slow a run. The default sink is `null_handler` (drops everything).
8
+ *
9
+ * @module
10
+ */
11
+
12
+ import type {PublishingEvent} from './publishing_event.js';
13
+
14
+ /** A sink for publishing events. */
15
+ export interface PublishingEventHandler {
16
+ emit: (event: PublishingEvent) => void;
17
+ }
18
+
19
+ /** A `capture_handler` also exposes the events it has collected. */
20
+ export interface CapturingEventHandler extends PublishingEventHandler {
21
+ readonly events: Array<PublishingEvent>;
22
+ }
23
+
24
+ /** Drops every event. The default when no handler is supplied. */
25
+ export const null_handler = (): PublishingEventHandler => ({
26
+ emit: () => {},
27
+ });
28
+
29
+ /** Collects events in memory. Used to build the run report and in tests. */
30
+ export const capture_handler = (): CapturingEventHandler => {
31
+ const events: Array<PublishingEvent> = [];
32
+ return {
33
+ events,
34
+ emit: (event) => {
35
+ events.push(event);
36
+ },
37
+ };
38
+ };
39
+
40
+ /**
41
+ * Writes each event as one JSON object per line (JSON-lines) to `process.stdout`.
42
+ * Write failures are swallowed — the stream is observability, not control flow.
43
+ */
44
+ export const stdout_handler = (): PublishingEventHandler => ({
45
+ emit: (event) => {
46
+ try {
47
+ process.stdout.write(JSON.stringify(event) + '\n');
48
+ } catch {
49
+ // best-effort: a logging sink must never fail a run
50
+ }
51
+ },
52
+ });
53
+
54
+ /** Fans an event out to every handler in order. */
55
+ export const multi_handler = (handlers: Array<PublishingEventHandler>): PublishingEventHandler => ({
56
+ emit: (event) => {
57
+ for (const handler of handlers) {
58
+ handler.emit(event);
59
+ }
60
+ },
61
+ });
62
+
63
+ /**
64
+ * Wraps a handler, masking secrets in each event's string fields before forwarding.
65
+ *
66
+ * @param inner - the handler to forward masked events to
67
+ * @param mask - the masking function, defaults to `mask_secrets`
68
+ */
69
+ export const masking_handler = (
70
+ inner: PublishingEventHandler,
71
+ mask: (event: PublishingEvent) => PublishingEvent = mask_secrets,
72
+ ): PublishingEventHandler => ({
73
+ emit: (event) => {
74
+ inner.emit(mask(event));
75
+ },
76
+ });
77
+
78
+ // Minimal redaction rules: npm auth tokens (bare or registry-scoped), `SECRET_*`
79
+ // env-style assignments, and `npm_`-prefixed tokens. Deliberately lean — error
80
+ // strings can carry npm/git output; a fuller secret catalog is deferred.
81
+ const SECRET_RULES: Array<readonly [RegExp, string]> = [
82
+ [/((?:\/\/[^\s:]+:)?_authToken\s*=\s*)\S+/gi, '$1[redacted]'],
83
+ [/(SECRET_[A-Z0-9_]+\s*[=:]\s*)\S+/g, '$1[redacted]'],
84
+ [/(npm_[A-Za-z0-9]{4})[A-Za-z0-9]{12,}/g, '$1[redacted]'],
85
+ ];
86
+
87
+ /** Redacts known secret shapes from a string. */
88
+ export const redact_secrets = (text: string): string =>
89
+ SECRET_RULES.reduce((acc, [pattern, replacement]) => acc.replace(pattern, replacement), text);
90
+
91
+ /** Returns a copy of the event with secrets redacted from its string-valued fields. */
92
+ export const mask_secrets = (event: PublishingEvent): PublishingEvent => {
93
+ const masked: Record<string, unknown> = {};
94
+ for (const [key, value] of Object.entries(event)) {
95
+ masked[key] = typeof value === 'string' ? redact_secrets(value) : value;
96
+ }
97
+ return masked as PublishingEvent;
98
+ };
@@ -2,9 +2,13 @@ import type {Logger} from '@fuzdev/fuz_util/log.js';
2
2
  import {styleText as st} from 'node:util';
3
3
 
4
4
  import type {LocalRepo} from './local_repo.js';
5
- import type {BumpType} from './semver.js';
6
5
  import {validate_dependency_graph} from './graph_validation.js';
7
- import {is_breaking_change, compare_bump_types, calculate_next_version} from './version_utils.js';
6
+ import {
7
+ type BumpType,
8
+ is_breaking_change,
9
+ compare_bump_types,
10
+ calculate_next_version,
11
+ } from './version_utils.js';
8
12
  import type {ChangesetOperations} from './operations.js';
9
13
  import {default_changeset_operations} from './operations_defaults.js';
10
14
  import {GITOPS_MAX_ITERATIONS_DEFAULT} from './gitops_constants.js';
@@ -36,7 +40,6 @@ export interface DependencyUpdate {
36
40
  current_version: string;
37
41
  new_version: string;
38
42
  type: 'dependencies' | 'devDependencies' | 'peerDependencies';
39
- causes_republish: boolean;
40
43
  }
41
44
 
42
45
  // Verbose data types for diagnostic output
@@ -178,6 +181,18 @@ export const generate_publishing_plan = async (
178
181
  const repo = repos.find((r) => r.library.name === pkg_name);
179
182
  if (!repo) continue;
180
183
 
184
+ // Private packages never publish — exclude them from version changes entirely (no
185
+ // publish step, npm-wait, bump escalation, or auto-changeset). They keep their slot in
186
+ // the topological order. Flag a private package that carries a changeset, since that
187
+ // changeset can't be published.
188
+ if (repo.library.package_json.private) {
189
+ const private_has_changesets = await ops.has_changesets({repo});
190
+ if (private_has_changesets.ok && private_has_changesets.value) {
191
+ warnings.push(`${pkg_name} is private — its changeset(s) will not be published`);
192
+ }
193
+ continue;
194
+ }
195
+
181
196
  // Check for changesets
182
197
  const has_result = await ops.has_changesets({repo});
183
198
 
@@ -267,6 +282,10 @@ export const generate_publishing_plan = async (
267
282
  for (const repo of repos) {
268
283
  const pkg_name = repo.library.name;
269
284
 
285
+ // Private packages are excluded from version changes (they never publish), so they
286
+ // never escalate or auto-generate a changeset.
287
+ if (repo.library.package_json.private) continue;
288
+
270
289
  // Get required bump from dependencies
271
290
  const required_bump = get_required_bump_for_dependencies(
272
291
  repo,
@@ -405,6 +424,7 @@ export const generate_publishing_plan = async (
405
424
 
406
425
  for (const repo of repos) {
407
426
  const pkg_name = repo.library.name;
427
+ if (repo.library.package_json.private) continue; // private packages never publish
408
428
  const required_bump = get_required_bump_for_dependencies(
409
429
  repo,
410
430
  pending_updates,