@fuzdev/fuz_gitops 0.72.0 → 0.73.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/README.md +13 -6
  2. package/dist/changeset_generator.d.ts.map +1 -1
  3. package/dist/changeset_generator.js +2 -16
  4. package/dist/changeset_reader.d.ts +1 -1
  5. package/dist/changeset_reader.d.ts.map +1 -1
  6. package/dist/dependency_graph.js +1 -1
  7. package/dist/git_operations.d.ts +5 -0
  8. package/dist/git_operations.d.ts.map +1 -1
  9. package/dist/git_operations.js +5 -0
  10. package/dist/gitops_analyze.task.d.ts +1 -0
  11. package/dist/gitops_analyze.task.d.ts.map +1 -1
  12. package/dist/gitops_analyze.task.js +12 -17
  13. package/dist/gitops_plan.task.d.ts +1 -0
  14. package/dist/gitops_plan.task.d.ts.map +1 -1
  15. package/dist/gitops_plan.task.js +9 -2
  16. package/dist/gitops_publish.task.d.ts +2 -2
  17. package/dist/gitops_publish.task.d.ts.map +1 -1
  18. package/dist/gitops_publish.task.js +83 -28
  19. package/dist/gitops_run.task.d.ts +2 -1
  20. package/dist/gitops_run.task.d.ts.map +1 -1
  21. package/dist/gitops_run.task.js +40 -7
  22. package/dist/gitops_sync.task.d.ts +1 -0
  23. package/dist/gitops_sync.task.d.ts.map +1 -1
  24. package/dist/gitops_sync.task.js +16 -2
  25. package/dist/gitops_task_helpers.d.ts +16 -2
  26. package/dist/gitops_task_helpers.d.ts.map +1 -1
  27. package/dist/gitops_task_helpers.js +11 -3
  28. package/dist/gitops_validate.task.d.ts +1 -0
  29. package/dist/gitops_validate.task.d.ts.map +1 -1
  30. package/dist/gitops_validate.task.js +20 -20
  31. package/dist/graph_validation.d.ts +16 -1
  32. package/dist/graph_validation.d.ts.map +1 -1
  33. package/dist/graph_validation.js +15 -0
  34. package/dist/local_repo.d.ts +22 -10
  35. package/dist/local_repo.d.ts.map +1 -1
  36. package/dist/local_repo.js +92 -75
  37. package/dist/log_helpers.d.ts +0 -5
  38. package/dist/log_helpers.d.ts.map +1 -1
  39. package/dist/log_helpers.js +8 -19
  40. package/dist/multi_repo_publisher.d.ts +21 -2
  41. package/dist/multi_repo_publisher.d.ts.map +1 -1
  42. package/dist/multi_repo_publisher.js +279 -287
  43. package/dist/operations.d.ts +6 -26
  44. package/dist/operations.d.ts.map +1 -1
  45. package/dist/operations_defaults.d.ts.map +1 -1
  46. package/dist/operations_defaults.js +1 -19
  47. package/dist/output_helpers.d.ts.map +1 -1
  48. package/dist/output_helpers.js +7 -6
  49. package/dist/paths.d.ts +0 -4
  50. package/dist/paths.d.ts.map +1 -1
  51. package/dist/paths.js +0 -4
  52. package/dist/publish_gate.d.ts +44 -0
  53. package/dist/publish_gate.d.ts.map +1 -0
  54. package/dist/publish_gate.js +32 -0
  55. package/dist/publish_steps.d.ts +60 -0
  56. package/dist/publish_steps.d.ts.map +1 -0
  57. package/dist/publish_steps.js +113 -0
  58. package/dist/publishing_event.d.ts +12 -19
  59. package/dist/publishing_event.d.ts.map +1 -1
  60. package/dist/publishing_event.js +16 -25
  61. package/dist/publishing_plan.d.ts +1 -2
  62. package/dist/publishing_plan.d.ts.map +1 -1
  63. package/dist/publishing_plan.js +18 -1
  64. package/dist/publishing_plan_helpers.d.ts +1 -1
  65. package/dist/publishing_plan_helpers.d.ts.map +1 -1
  66. package/dist/publishing_plan_helpers.js +2 -15
  67. package/dist/publishing_plan_logging.d.ts.map +1 -1
  68. package/dist/publishing_plan_logging.js +15 -23
  69. package/dist/version_utils.d.ts +17 -3
  70. package/dist/version_utils.d.ts.map +1 -1
  71. package/dist/version_utils.js +22 -0
  72. package/package.json +5 -6
  73. package/src/lib/changeset_generator.ts +10 -19
  74. package/src/lib/changeset_reader.ts +1 -2
  75. package/src/lib/dependency_graph.ts +1 -1
  76. package/src/lib/git_operations.ts +5 -0
  77. package/src/lib/gitops_analyze.task.ts +18 -23
  78. package/src/lib/gitops_plan.task.ts +10 -2
  79. package/src/lib/gitops_publish.task.ts +92 -27
  80. package/src/lib/gitops_run.task.ts +39 -7
  81. package/src/lib/gitops_sync.task.ts +18 -3
  82. package/src/lib/gitops_task_helpers.ts +20 -3
  83. package/src/lib/gitops_validate.task.ts +30 -22
  84. package/src/lib/graph_validation.ts +26 -0
  85. package/src/lib/local_repo.ts +114 -83
  86. package/src/lib/log_helpers.ts +8 -26
  87. package/src/lib/multi_repo_publisher.ts +336 -353
  88. package/src/lib/operations.ts +6 -22
  89. package/src/lib/operations_defaults.ts +1 -19
  90. package/src/lib/output_helpers.ts +7 -6
  91. package/src/lib/paths.ts +0 -5
  92. package/src/lib/publish_gate.ts +53 -0
  93. package/src/lib/publish_steps.ts +149 -0
  94. package/src/lib/publishing_event.ts +16 -25
  95. package/src/lib/publishing_plan.ts +23 -3
  96. package/src/lib/publishing_plan_helpers.ts +5 -18
  97. package/src/lib/publishing_plan_logging.ts +19 -39
  98. package/src/lib/version_utils.ts +30 -9
  99. package/dist/npm_install_helpers.d.ts +0 -23
  100. package/dist/npm_install_helpers.d.ts.map +0 -1
  101. package/dist/npm_install_helpers.js +0 -60
  102. package/dist/semver.d.ts +0 -26
  103. package/dist/semver.d.ts.map +0 -1
  104. package/dist/semver.js +0 -137
  105. package/dist/serialization_types.d.ts +0 -59
  106. package/dist/serialization_types.d.ts.map +0 -1
  107. package/dist/serialization_types.js +0 -42
  108. package/src/lib/npm_install_helpers.ts +0 -85
  109. package/src/lib/semver.ts +0 -170
  110. package/src/lib/serialization_types.ts +0 -92
@@ -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
+ };
@@ -26,6 +26,10 @@ export const PublishingErrorCode = z.enum([
26
26
  'auth',
27
27
  'dependency',
28
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',
29
33
  'other',
30
34
  ]);
31
35
  export type PublishingErrorCode = z.infer<typeof PublishingErrorCode>;
@@ -50,17 +54,6 @@ export const PublishingEvent = z.discriminatedUnion('event', [
50
54
  wetrun: z.boolean().meta({description: 'false means every package_completed is a prediction'}),
51
55
  total: z.number(),
52
56
  }),
53
- z.strictObject({
54
- event: z.literal('iteration_started'),
55
- iteration: z.number(),
56
- max: z.number(),
57
- }),
58
- z.strictObject({
59
- event: z.literal('iteration_finished'),
60
- iteration: z.number(),
61
- published_count: z.number(),
62
- converged: z.boolean(),
63
- }),
64
57
  z.strictObject({
65
58
  event: z.literal('package_skipped'),
66
59
  name: z.string(),
@@ -71,12 +64,17 @@ export const PublishingEvent = z.discriminatedUnion('event', [
71
64
  name: z.string(),
72
65
  old_version: z.string(),
73
66
  new_version: z.string(),
74
- // mirrors `BumpType` from `semver.ts`; inline so the event schema is self-contained
67
+ // mirrors `BumpType` from `version_utils.ts`; inline so the event schema is self-contained
75
68
  bump_type: z.enum(['major', 'minor', 'patch']),
76
69
  breaking: z.boolean(),
77
70
  commit: z.string().meta({description: "'simulated' in a dry run, otherwise the commit hash"}),
78
71
  tag: z.string(),
79
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
+ }),
80
78
  z.strictObject({
81
79
  event: z.literal('package_failed'),
82
80
  name: z.string(),
@@ -88,19 +86,12 @@ export const PublishingEvent = z.discriminatedUnion('event', [
88
86
  dependent: z.string(),
89
87
  dependency: z.string(),
90
88
  version: z.string(),
91
- }),
92
- z.strictObject({
93
- event: z.literal('install_started'),
94
- name: z.string(),
95
- }),
96
- z.strictObject({
97
- event: z.literal('install_completed'),
98
- name: z.string(),
99
- }),
100
- z.strictObject({
101
- event: z.literal('install_failed'),
102
- name: z.string(),
103
- error: 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(),
104
95
  }),
105
96
  z.strictObject({
106
97
  event: z.literal('deploy_started'),
@@ -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,
@@ -7,8 +7,7 @@
7
7
  */
8
8
 
9
9
  import type {LocalRepo} from './local_repo.js';
10
- import type {BumpType} from './semver.js';
11
- import {needs_update} from './version_utils.js';
10
+ import {needs_update, required_bump_for_dependency_update, type BumpType} from './version_utils.js';
12
11
  import type {DependencyUpdate} from './publishing_plan.js';
13
12
 
14
13
  /**
@@ -42,7 +41,6 @@ export const calculate_dependency_updates = (
42
41
  current_version,
43
42
  new_version,
44
43
  type: 'dependencies',
45
- causes_republish: true,
46
44
  });
47
45
 
48
46
  if (breaking_packages.has(dep_name)) {
@@ -67,7 +65,6 @@ export const calculate_dependency_updates = (
67
65
  current_version,
68
66
  new_version,
69
67
  type: 'peerDependencies',
70
- causes_republish: true,
71
68
  });
72
69
 
73
70
  if (breaking_packages.has(dep_name)) {
@@ -92,7 +89,6 @@ export const calculate_dependency_updates = (
92
89
  current_version,
93
90
  new_version,
94
91
  type: 'devDependencies',
95
- causes_republish: false,
96
92
  });
97
93
  }
98
94
  }
@@ -131,17 +127,8 @@ export const get_required_bump_for_dependencies = (
131
127
  breaking_packages.has(update.updated_dependency),
132
128
  );
133
129
 
134
- const current_version = repo.library.package_json.version || '0.0.0';
135
- const [major] = current_version.split('.').map(Number);
136
- const is_pre_1_0 = major === 0;
137
-
138
- if (has_breaking_deps) {
139
- // Breaking changes propagate
140
- // Pre-1.0: use minor for breaking changes
141
- // 1.0+: use major for breaking changes
142
- return is_pre_1_0 ? 'minor' : 'major';
143
- }
144
-
145
- // For non-breaking dependency updates, use patch
146
- return 'patch';
130
+ return required_bump_for_dependency_update(
131
+ repo.library.package_json.version || '0.0.0',
132
+ has_breaking_deps,
133
+ );
147
134
  };
@@ -182,54 +182,34 @@ export const log_publishing_plan = (
182
182
  const with_escalation = ordered_changes.filter((vc) => vc.needs_bump_escalation);
183
183
  const with_auto_changesets = ordered_changes.filter((vc) => vc.will_generate_changeset);
184
184
 
185
- // Log each group with diff-style output
186
- if (with_changesets.length > 0) {
187
- log.info(st('cyan', 'Version Changes (from changesets):'));
188
- for (const change of with_changesets) {
189
- const idx = ordered_changes.indexOf(change);
185
+ // A single running counter across all groups so positions read 1..N with no
186
+ // gaps. (Numbering per-group index would make the first group skip the numbers
187
+ // that land in a later group, e.g. jump from `[9/12]` to `[11/12]`.)
188
+ const total = with_changesets.length + with_escalation.length + with_auto_changesets.length;
189
+ let position = 0;
190
+ const log_change_group = (
191
+ title: string,
192
+ color: 'cyan' | 'yellow',
193
+ changes: Array<VersionChange>,
194
+ ): void => {
195
+ if (changes.length === 0) return;
196
+ log.info(st(color, title));
197
+ for (const change of changes) {
190
198
  log_version_change_with_diffs(
191
199
  change,
192
- idx,
193
- ordered_changes.length,
200
+ position++,
201
+ total,
194
202
  dependency_updates,
195
203
  breaking_cascades,
196
204
  log,
197
205
  );
198
206
  }
199
207
  log.info('');
200
- }
201
-
202
- if (with_escalation.length > 0) {
203
- log.info(st('yellow', 'Version Changes (bump escalation):'));
204
- for (const change of with_escalation) {
205
- const idx = ordered_changes.indexOf(change);
206
- log_version_change_with_diffs(
207
- change,
208
- idx,
209
- ordered_changes.length,
210
- dependency_updates,
211
- breaking_cascades,
212
- log,
213
- );
214
- }
215
- log.info('');
216
- }
208
+ };
217
209
 
218
- if (with_auto_changesets.length > 0) {
219
- log.info(st('cyan', 'Version Changes (auto-generated):'));
220
- for (const change of with_auto_changesets) {
221
- const idx = ordered_changes.indexOf(change);
222
- log_version_change_with_diffs(
223
- change,
224
- idx,
225
- ordered_changes.length,
226
- dependency_updates,
227
- breaking_cascades,
228
- log,
229
- );
230
- }
231
- log.info('');
232
- }
210
+ log_change_group('Version Changes (from changesets):', 'cyan', with_changesets);
211
+ log_change_group('Version Changes (bump escalation):', 'yellow', with_escalation);
212
+ log_change_group('Version Changes (auto-generated):', 'cyan', with_auto_changesets);
233
213
  } else {
234
214
  log.info(st('dim', 'No packages to publish'));
235
215
  log.info('');
@@ -1,6 +1,7 @@
1
1
  // TODO: candidate for extraction to `@fuzdev/fuz_util`
2
2
 
3
- import type {BumpType} from './semver.js';
3
+ /** A semver bump kind: `major`, `minor`, or `patch`. */
4
+ export type BumpType = 'major' | 'minor' | 'patch';
4
5
 
5
6
  export const is_wildcard = (version: string): boolean => {
6
7
  return version === '*';
@@ -100,10 +101,7 @@ export const get_update_prefix = (
100
101
  * Pre-1.0: minor bumps are breaking
101
102
  * 1.0+: major bumps are breaking
102
103
  */
103
- export const is_breaking_change = (
104
- old_version: string,
105
- bump_type: 'major' | 'minor' | 'patch',
106
- ): boolean => {
104
+ export const is_breaking_change = (old_version: string, bump_type: BumpType): boolean => {
107
105
  const [major] = old_version.split('.').map(Number);
108
106
  const is_pre_1_0 = major === 0;
109
107
 
@@ -116,10 +114,33 @@ export const is_breaking_change = (
116
114
  }
117
115
  };
118
116
 
119
- export const detect_bump_type = (
120
- old_version: string,
121
- new_version: string,
122
- ): 'major' | 'minor' | 'patch' => {
117
+ /**
118
+ * The bump a package must take when one of its prod/peer dependencies updates.
119
+ * Pre-1.0: `minor` for a breaking dependency, otherwise `patch`.
120
+ * 1.0+: `major` for a breaking dependency, otherwise `patch`.
121
+ *
122
+ * Single source of truth for the dependency-driven bump rule, shared by the plan
123
+ * (`get_required_bump_for_dependencies`) and the auto-changeset generator
124
+ * (`calculate_required_bump`) so the two never drift.
125
+ *
126
+ * @param current_version - the package's current version, used to detect the pre-1.0 regime
127
+ * @param has_breaking_deps - whether any updated dependency is a breaking change
128
+ */
129
+ export const required_bump_for_dependency_update = (
130
+ current_version: string,
131
+ has_breaking_deps: boolean,
132
+ ): BumpType => {
133
+ const [major] = current_version.split('.').map(Number);
134
+ const is_pre_1_0 = major === 0;
135
+ if (has_breaking_deps) {
136
+ // Breaking changes propagate: pre-1.0 uses minor, 1.0+ uses major
137
+ return is_pre_1_0 ? 'minor' : 'major';
138
+ }
139
+ // Non-breaking dependency updates only need a patch
140
+ return 'patch';
141
+ };
142
+
143
+ export const detect_bump_type = (old_version: string, new_version: string): BumpType => {
123
144
  const old_parts = old_version.split('.').map(Number);
124
145
  const new_parts = new_version.split('.').map(Number);
125
146