@fuzdev/fuz_gitops 0.71.0 ā 0.72.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.
- package/dist/gitops_publish.task.d.ts +1 -0
- package/dist/gitops_publish.task.d.ts.map +1 -1
- package/dist/gitops_publish.task.js +10 -1
- package/dist/multi_repo_publisher.d.ts +8 -0
- package/dist/multi_repo_publisher.d.ts.map +1 -1
- package/dist/multi_repo_publisher.js +123 -41
- package/dist/publishing_event.d.ts +130 -0
- package/dist/publishing_event.d.ts.map +1 -0
- package/dist/publishing_event.js +149 -0
- package/dist/publishing_event_handler.d.ts +42 -0
- package/dist/publishing_event_handler.d.ts.map +1 -0
- package/dist/publishing_event_handler.js +75 -0
- package/dist/repo_ops.d.ts +1 -1
- package/dist/repo_ops.js +1 -1
- package/package.json +3 -3
- package/src/lib/gitops_publish.task.ts +10 -0
- package/src/lib/multi_repo_publisher.ts +176 -41
- package/src/lib/publishing_event.ts +160 -0
- package/src/lib/publishing_event_handler.ts +98 -0
- package/src/lib/repo_ops.ts +1 -1
|
@@ -15,6 +15,16 @@ import {
|
|
|
15
15
|
GITOPS_NPM_WAIT_TIMEOUT_DEFAULT,
|
|
16
16
|
} from './gitops_constants.js';
|
|
17
17
|
import {install_with_cache_healing} from './npm_install_helpers.js';
|
|
18
|
+
import {
|
|
19
|
+
type PublishingEvent,
|
|
20
|
+
type PublishingRunSummary,
|
|
21
|
+
summarize_events,
|
|
22
|
+
} from './publishing_event.js';
|
|
23
|
+
import {
|
|
24
|
+
type PublishingEventHandler,
|
|
25
|
+
capture_handler,
|
|
26
|
+
multi_handler,
|
|
27
|
+
} from './publishing_event_handler.js';
|
|
18
28
|
|
|
19
29
|
export interface PublishingOptions {
|
|
20
30
|
wetrun: boolean;
|
|
@@ -25,6 +35,8 @@ export interface PublishingOptions {
|
|
|
25
35
|
skip_install?: boolean;
|
|
26
36
|
log?: Logger;
|
|
27
37
|
ops?: GitopsOperations;
|
|
38
|
+
/** Structured event sink; defaults to capture-only (events surface on the result). */
|
|
39
|
+
events?: PublishingEventHandler;
|
|
28
40
|
}
|
|
29
41
|
|
|
30
42
|
export interface PublishedVersion {
|
|
@@ -42,6 +54,10 @@ export interface PublishingResult {
|
|
|
42
54
|
published: Array<PublishedVersion>;
|
|
43
55
|
failed: Array<{name: string; error: Error}>;
|
|
44
56
|
duration: number;
|
|
57
|
+
/** The structured event stream for this run, in emission order. */
|
|
58
|
+
events: Array<PublishingEvent>;
|
|
59
|
+
/** Tallied outcome, derived from `events`. */
|
|
60
|
+
summary: PublishingRunSummary;
|
|
45
61
|
}
|
|
46
62
|
|
|
47
63
|
export const publish_repos = async (
|
|
@@ -51,6 +67,13 @@ export const publish_repos = async (
|
|
|
51
67
|
const start_time = Date.now();
|
|
52
68
|
const {wetrun, update_deps, log, ops = default_gitops_operations} = options;
|
|
53
69
|
|
|
70
|
+
// Capture every event for the result; also forward to the caller's sink if provided.
|
|
71
|
+
const capture = capture_handler();
|
|
72
|
+
const events_handler = options.events ? multi_handler([options.events, capture]) : capture;
|
|
73
|
+
const emit = (event: PublishingEvent): void => {
|
|
74
|
+
events_handler.emit(event);
|
|
75
|
+
};
|
|
76
|
+
|
|
54
77
|
// Preflight checks (skip for dry runs since we're not actually publishing)
|
|
55
78
|
if (wetrun) {
|
|
56
79
|
const preflight_options: PreflightOptions = {
|
|
@@ -82,9 +105,12 @@ export const publish_repos = async (
|
|
|
82
105
|
log_order: true,
|
|
83
106
|
});
|
|
84
107
|
|
|
108
|
+
emit({event: 'run_started', wetrun, total: order.length});
|
|
109
|
+
|
|
85
110
|
const published: Map<string, PublishedVersion> = new Map();
|
|
86
111
|
const failed: Map<string, Error> = new Map();
|
|
87
112
|
const changed_repos: Set<string> = new Set(); // Track repos with any changes for selective deployment
|
|
113
|
+
const skipped_packages: Set<string> = new Set(); // dedupe the package_skipped event across iterations
|
|
88
114
|
|
|
89
115
|
// Fixed-point iteration: keep publishing until no new changesets are created
|
|
90
116
|
// This handles transitive dependency updates (auto-generated changesets)
|
|
@@ -93,8 +119,12 @@ export const publish_repos = async (
|
|
|
93
119
|
|
|
94
120
|
while (!converged && iteration < GITOPS_MAX_ITERATIONS_DEFAULT) {
|
|
95
121
|
iteration++;
|
|
122
|
+
emit({event: 'iteration_started', iteration, max: GITOPS_MAX_ITERATIONS_DEFAULT});
|
|
96
123
|
log?.info(
|
|
97
|
-
st(
|
|
124
|
+
st(
|
|
125
|
+
'cyan',
|
|
126
|
+
`\nš ${wetrun ? 'Publishing' : 'Dry run'} iteration ${iteration}/${GITOPS_MAX_ITERATIONS_DEFAULT}...\n`,
|
|
127
|
+
),
|
|
98
128
|
);
|
|
99
129
|
|
|
100
130
|
// Track if any packages were published in this iteration
|
|
@@ -121,6 +151,7 @@ export const publish_repos = async (
|
|
|
121
151
|
// Failed to check changesets
|
|
122
152
|
const err = new Error(`Failed to check changesets: ${has_result.message}`);
|
|
123
153
|
failed.set(pkg_name, err);
|
|
154
|
+
emit({event: 'package_failed', name: pkg_name, error: err.message, code: 'dependency'});
|
|
124
155
|
log?.error(st('red', ` ā ${err.message}`));
|
|
125
156
|
break;
|
|
126
157
|
}
|
|
@@ -129,6 +160,11 @@ export const publish_repos = async (
|
|
|
129
160
|
// Skip packages without changesets
|
|
130
161
|
// In real publish: They might get auto-changesets during dependency updates
|
|
131
162
|
// In dry run: We can't simulate auto-changesets, so just skip
|
|
163
|
+
// Emit once per package ā the loop revisits no-changeset packages each iteration
|
|
164
|
+
if (!skipped_packages.has(pkg_name)) {
|
|
165
|
+
skipped_packages.add(pkg_name);
|
|
166
|
+
emit({event: 'package_skipped', name: pkg_name, reason: 'no changesets'});
|
|
167
|
+
}
|
|
132
168
|
if (!wetrun) {
|
|
133
169
|
// Silent skip in dry run - plan shows which packages get auto-changesets
|
|
134
170
|
continue;
|
|
@@ -140,7 +176,12 @@ export const publish_repos = async (
|
|
|
140
176
|
|
|
141
177
|
try {
|
|
142
178
|
// 1. Publish this package
|
|
143
|
-
log?.info(
|
|
179
|
+
log?.info(
|
|
180
|
+
st(
|
|
181
|
+
'dim',
|
|
182
|
+
` [${i + 1}/${order.length}] ${wetrun ? 'Publishing' : 'Would publish'} ${pkg_name}...`,
|
|
183
|
+
),
|
|
184
|
+
);
|
|
144
185
|
const version = await publish_single_repo(repo, options, ops);
|
|
145
186
|
published.set(pkg_name, version);
|
|
146
187
|
changed_repos.add(pkg_name); // Mark as changed for deployment
|
|
@@ -148,7 +189,21 @@ export const publish_repos = async (
|
|
|
148
189
|
// (their dependencies didn't change, only their version)
|
|
149
190
|
published_in_iteration = true;
|
|
150
191
|
published_count++;
|
|
151
|
-
|
|
192
|
+
emit({
|
|
193
|
+
event: 'package_completed',
|
|
194
|
+
name: pkg_name,
|
|
195
|
+
old_version: version.old_version,
|
|
196
|
+
new_version: version.new_version,
|
|
197
|
+
bump_type: version.bump_type,
|
|
198
|
+
breaking: version.breaking,
|
|
199
|
+
commit: version.commit,
|
|
200
|
+
tag: version.tag,
|
|
201
|
+
});
|
|
202
|
+
log?.info(
|
|
203
|
+
wetrun
|
|
204
|
+
? st('green', ` ā
Published ${pkg_name}@${version.new_version}`)
|
|
205
|
+
: st('cyan', ` ā Would publish ${pkg_name}@${version.new_version}`),
|
|
206
|
+
);
|
|
152
207
|
|
|
153
208
|
if (wetrun) {
|
|
154
209
|
// 2. Wait for this package to be available on NPM
|
|
@@ -166,9 +221,16 @@ export const publish_repos = async (
|
|
|
166
221
|
});
|
|
167
222
|
|
|
168
223
|
if (!wait_result.ok) {
|
|
169
|
-
throw
|
|
224
|
+
// Handle inline (don't throw into the generic catch): the npm-wait failure
|
|
225
|
+
// carries a typed `timeout` signal, so we know this is a network failure
|
|
226
|
+
// without sniffing the message.
|
|
227
|
+
const err = new Error(
|
|
170
228
|
`Failed to wait for package: ${wait_result.message}${wait_result.timeout ? ' (timeout)' : ''}`,
|
|
171
229
|
);
|
|
230
|
+
failed.set(pkg_name, err);
|
|
231
|
+
emit({event: 'package_failed', name: pkg_name, error: err.message, code: 'network'});
|
|
232
|
+
log?.error(st('red', ` ā Failed to publish ${pkg_name}: ${err.message}`));
|
|
233
|
+
break; // fail fast
|
|
172
234
|
}
|
|
173
235
|
|
|
174
236
|
// 3. Update all repos that have prod/peer deps on this package
|
|
@@ -197,6 +259,12 @@ export const publish_repos = async (
|
|
|
197
259
|
log?.info(
|
|
198
260
|
` Updating ${dependent_repo.library.name}'s dependency on ${pkg_name}`,
|
|
199
261
|
);
|
|
262
|
+
emit({
|
|
263
|
+
event: 'dependency_updated',
|
|
264
|
+
dependent: dependent_repo.library.name,
|
|
265
|
+
dependency: pkg_name,
|
|
266
|
+
version: version.new_version,
|
|
267
|
+
});
|
|
200
268
|
changed_repos.add(dependent_repo.library.name); // Mark as changed for deployment
|
|
201
269
|
changed_in_iteration.add(dependent_repo.library.name); // Track for batch install
|
|
202
270
|
await update_package_json(dependent_repo, updates, {
|
|
@@ -212,6 +280,15 @@ export const publish_repos = async (
|
|
|
212
280
|
} catch (error) {
|
|
213
281
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
214
282
|
failed.set(pkg_name, err);
|
|
283
|
+
emit({
|
|
284
|
+
event: 'package_failed',
|
|
285
|
+
name: pkg_name,
|
|
286
|
+
error: err.message,
|
|
287
|
+
// TODO: emit a precise code once the npm/process ops return typed errors ā
|
|
288
|
+
// today a publish-step cause lives in unstructured stderr, so use the honest
|
|
289
|
+
// coarse bucket rather than guessing 'auth'/'network'/'build' from the message.
|
|
290
|
+
code: 'publish',
|
|
291
|
+
});
|
|
215
292
|
log?.error(st('red', ` ā Failed to publish ${pkg_name}: ${err.message}`));
|
|
216
293
|
break; // Always fail fast on error
|
|
217
294
|
}
|
|
@@ -221,33 +298,39 @@ export const publish_repos = async (
|
|
|
221
298
|
// This ensures workspace stays consistent before next iteration
|
|
222
299
|
if (wetrun && !options.skip_install && changed_in_iteration.size > 0) {
|
|
223
300
|
log?.info(st('cyan', '\nš¦ Installing dependencies for updated repos...\n'));
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
const repo = repos.find((r) => r.library.name === pkg_name);
|
|
227
|
-
if (!repo) continue;
|
|
228
|
-
|
|
229
|
-
try {
|
|
230
|
-
log?.info(` Installing ${pkg_name}...`);
|
|
231
|
-
await install_with_cache_healing(repo, ops, log);
|
|
232
|
-
log?.info(st('green', ` ā
Installed ${pkg_name}`));
|
|
233
|
-
} catch (error) {
|
|
234
|
-
const err = error instanceof Error ? error : new Error(String(error));
|
|
235
|
-
failed.set(pkg_name, err);
|
|
236
|
-
log?.error(st('red', ` ā Failed to install ${pkg_name}: ${err.message}`));
|
|
237
|
-
// Continue with other installs instead of breaking
|
|
238
|
-
}
|
|
301
|
+
for (const [name, err] of await install_repos(changed_in_iteration, repos, ops, emit, log)) {
|
|
302
|
+
failed.set(name, err);
|
|
239
303
|
}
|
|
240
304
|
}
|
|
241
305
|
|
|
242
306
|
// Log iteration summary
|
|
243
307
|
if (published_count > 0) {
|
|
244
|
-
log?.info(
|
|
308
|
+
log?.info(
|
|
309
|
+
st(
|
|
310
|
+
'dim',
|
|
311
|
+
`\nIteration ${iteration}: ${published_count} package(s) ${wetrun ? 'published' : 'would be published'}\n`,
|
|
312
|
+
),
|
|
313
|
+
);
|
|
245
314
|
}
|
|
246
315
|
|
|
316
|
+
emit({
|
|
317
|
+
event: 'iteration_finished',
|
|
318
|
+
iteration,
|
|
319
|
+
published_count,
|
|
320
|
+
converged: !published_in_iteration,
|
|
321
|
+
});
|
|
322
|
+
|
|
247
323
|
// Check for convergence: no packages published in this iteration
|
|
248
324
|
if (!published_in_iteration) {
|
|
249
325
|
converged = true;
|
|
250
|
-
log?.info(
|
|
326
|
+
log?.info(
|
|
327
|
+
st(
|
|
328
|
+
'green',
|
|
329
|
+
wetrun
|
|
330
|
+
? `\nā Converged after ${iteration} iteration(s) - no new changesets\n`
|
|
331
|
+
: `\nā Dry run complete after ${iteration} iteration(s)\n`,
|
|
332
|
+
),
|
|
333
|
+
);
|
|
251
334
|
} else if (iteration === GITOPS_MAX_ITERATIONS_DEFAULT) {
|
|
252
335
|
// Count packages that still have changesets (not yet published)
|
|
253
336
|
const pending_count = order.length - published.size;
|
|
@@ -285,6 +368,14 @@ export const publish_repos = async (
|
|
|
285
368
|
|
|
286
369
|
if (dev_updates.size > 0) {
|
|
287
370
|
log?.info(` Updating ${dev_updates.size} dev dependencies in ${repo.library.name}`);
|
|
371
|
+
for (const [dep_name, dep_version] of dev_updates) {
|
|
372
|
+
emit({
|
|
373
|
+
event: 'dependency_updated',
|
|
374
|
+
dependent: repo.library.name,
|
|
375
|
+
dependency: dep_name,
|
|
376
|
+
version: dep_version,
|
|
377
|
+
});
|
|
378
|
+
}
|
|
288
379
|
changed_repos.add(repo.library.name); // Mark as changed for deployment
|
|
289
380
|
dev_updated_repos.add(repo.library.name); // Track for batch install
|
|
290
381
|
await update_package_json(repo, dev_updates, {
|
|
@@ -300,21 +391,8 @@ export const publish_repos = async (
|
|
|
300
391
|
// Phase 2b: Install dev dependencies for repos with dev dep updates
|
|
301
392
|
if (wetrun && !options.skip_install && dev_updated_repos.size > 0) {
|
|
302
393
|
log?.info(st('cyan', '\nš¦ Installing dev dependencies for updated repos...\n'));
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
const repo = repos.find((r) => r.library.name === pkg_name);
|
|
306
|
-
if (!repo) continue;
|
|
307
|
-
|
|
308
|
-
try {
|
|
309
|
-
log?.info(` Installing ${pkg_name}...`);
|
|
310
|
-
await install_with_cache_healing(repo, ops, log);
|
|
311
|
-
log?.info(st('green', ` ā
Installed ${pkg_name}`));
|
|
312
|
-
} catch (error) {
|
|
313
|
-
const err = error instanceof Error ? error : new Error(String(error));
|
|
314
|
-
failed.set(pkg_name, err);
|
|
315
|
-
log?.error(st('red', ` ā Failed to install ${pkg_name}: ${err.message}`));
|
|
316
|
-
// Continue with other installs instead of breaking
|
|
317
|
-
}
|
|
394
|
+
for (const [name, err] of await install_repos(dev_updated_repos, repos, ops, emit, log)) {
|
|
395
|
+
failed.set(name, err);
|
|
318
396
|
}
|
|
319
397
|
}
|
|
320
398
|
|
|
@@ -331,6 +409,7 @@ export const publish_repos = async (
|
|
|
331
409
|
|
|
332
410
|
for (const repo of repos_to_deploy) {
|
|
333
411
|
try {
|
|
412
|
+
emit({event: 'deploy_started', name: repo.library.name});
|
|
334
413
|
log?.info(` Deploying ${repo.library.name}...`);
|
|
335
414
|
const deploy_result = await ops.process.spawn({
|
|
336
415
|
cmd: 'gro',
|
|
@@ -339,12 +418,16 @@ export const publish_repos = async (
|
|
|
339
418
|
});
|
|
340
419
|
|
|
341
420
|
if (deploy_result.ok) {
|
|
421
|
+
emit({event: 'deploy_completed', name: repo.library.name});
|
|
342
422
|
log?.info(st('green', ` ā
Deployed ${repo.library.name}`));
|
|
343
423
|
} else {
|
|
424
|
+
emit({event: 'deploy_failed', name: repo.library.name, error: deploy_result.message});
|
|
344
425
|
log?.warn(st('yellow', ` ā ļø Failed to deploy ${repo.library.name}`));
|
|
345
426
|
}
|
|
346
427
|
} catch (error) {
|
|
347
|
-
|
|
428
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
429
|
+
emit({event: 'deploy_failed', name: repo.library.name, error: err.message});
|
|
430
|
+
log?.error(st('red', ` ā Error deploying ${repo.library.name}: ${err.message}`));
|
|
348
431
|
}
|
|
349
432
|
}
|
|
350
433
|
}
|
|
@@ -352,25 +435,44 @@ export const publish_repos = async (
|
|
|
352
435
|
// Summary
|
|
353
436
|
const duration = Date.now() - start_time;
|
|
354
437
|
const ok = failed.size === 0;
|
|
438
|
+
const summary = summarize_events(capture.events, duration);
|
|
355
439
|
|
|
356
|
-
log?.info(st('cyan',
|
|
440
|
+
log?.info(st('cyan', `\nš ${wetrun ? 'Publishing' : 'Dry Run'} Summary\n`));
|
|
357
441
|
log?.info(` Duration: ${(duration / 1000).toFixed(1)}s`);
|
|
358
|
-
log?.info(` Published: ${published.size} packages`);
|
|
442
|
+
log?.info(` ${wetrun ? 'Published' : 'Would publish'}: ${published.size} packages`);
|
|
359
443
|
if (failed.size > 0) {
|
|
360
444
|
log?.info(` Failed: ${failed.size} packages`);
|
|
361
445
|
}
|
|
362
446
|
|
|
363
447
|
if (ok) {
|
|
364
|
-
log?.info(
|
|
448
|
+
log?.info(
|
|
449
|
+
st(
|
|
450
|
+
'green',
|
|
451
|
+
wetrun
|
|
452
|
+
? '\n⨠All packages published successfully!\n'
|
|
453
|
+
: `\n⨠Dry run complete ā ${published.size} package(s) would be published. Re-run with --wetrun to publish.\n`,
|
|
454
|
+
),
|
|
455
|
+
);
|
|
365
456
|
} else {
|
|
366
|
-
log?.error(
|
|
457
|
+
log?.error(
|
|
458
|
+
st(
|
|
459
|
+
'red',
|
|
460
|
+
wetrun
|
|
461
|
+
? '\nā Some packages failed to publish\n'
|
|
462
|
+
: '\nā Some packages failed during dry run\n',
|
|
463
|
+
),
|
|
464
|
+
);
|
|
367
465
|
}
|
|
368
466
|
|
|
467
|
+
emit({event: 'run_finished', summary});
|
|
468
|
+
|
|
369
469
|
return {
|
|
370
470
|
ok,
|
|
371
471
|
published: Array.from(published.values()),
|
|
372
472
|
failed: Array.from(failed.entries()).map(([name, error]) => ({name, error})),
|
|
373
473
|
duration,
|
|
474
|
+
events: capture.events,
|
|
475
|
+
summary,
|
|
374
476
|
};
|
|
375
477
|
};
|
|
376
478
|
|
|
@@ -465,3 +567,36 @@ const publish_single_repo = async (
|
|
|
465
567
|
tag: `v${new_version}`,
|
|
466
568
|
};
|
|
467
569
|
};
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* Installs dependencies for each named repo (with cache healing), emitting install
|
|
573
|
+
* events and logging progress. A failed install doesn't stop the batch ā failures are
|
|
574
|
+
* collected and returned so the caller can fold them into the run's failures.
|
|
575
|
+
*/
|
|
576
|
+
const install_repos = async (
|
|
577
|
+
names: Iterable<string>,
|
|
578
|
+
repos: Array<LocalRepo>,
|
|
579
|
+
ops: GitopsOperations,
|
|
580
|
+
emit: (event: PublishingEvent) => void,
|
|
581
|
+
log?: Logger,
|
|
582
|
+
): Promise<Map<string, Error>> => {
|
|
583
|
+
const failures: Map<string, Error> = new Map();
|
|
584
|
+
for (const name of names) {
|
|
585
|
+
const repo = repos.find((r) => r.library.name === name);
|
|
586
|
+
if (!repo) continue;
|
|
587
|
+
try {
|
|
588
|
+
emit({event: 'install_started', name});
|
|
589
|
+
log?.info(` Installing ${name}...`);
|
|
590
|
+
await install_with_cache_healing(repo, ops, log);
|
|
591
|
+
emit({event: 'install_completed', name});
|
|
592
|
+
log?.info(st('green', ` ā
Installed ${name}`));
|
|
593
|
+
} catch (error) {
|
|
594
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
595
|
+
failures.set(name, err);
|
|
596
|
+
emit({event: 'install_failed', name, error: err.message});
|
|
597
|
+
log?.error(st('red', ` ā Failed to install ${name}: ${err.message}`));
|
|
598
|
+
// continue with other installs instead of breaking
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
return failures;
|
|
602
|
+
};
|
|
@@ -0,0 +1,160 @@
|
|
|
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
|
+
'other',
|
|
30
|
+
]);
|
|
31
|
+
export type PublishingErrorCode = z.infer<typeof PublishingErrorCode>;
|
|
32
|
+
|
|
33
|
+
/** Tallied outcome of a publishing run, derived from its events via `summarize_events`. */
|
|
34
|
+
export const PublishingRunSummary = z.strictObject({
|
|
35
|
+
total: z.number().meta({description: 'packages in the publishing order (the candidate set)'}),
|
|
36
|
+
published: z.number().meta({description: 'packages published (or, in a dry run, predicted)'}),
|
|
37
|
+
failed: z.number(),
|
|
38
|
+
skipped: z.number(),
|
|
39
|
+
duration: z.number().meta({description: 'wall-clock duration in milliseconds'}),
|
|
40
|
+
});
|
|
41
|
+
export type PublishingRunSummary = z.infer<typeof PublishingRunSummary>;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* A single structured event emitted during a publishing run. Tagged on `event` so the
|
|
45
|
+
* union serializes as one self-describing JSON object per event (JSON-lines on the wire).
|
|
46
|
+
*/
|
|
47
|
+
export const PublishingEvent = z.discriminatedUnion('event', [
|
|
48
|
+
z.strictObject({
|
|
49
|
+
event: z.literal('run_started'),
|
|
50
|
+
wetrun: z.boolean().meta({description: 'false means every package_completed is a prediction'}),
|
|
51
|
+
total: z.number(),
|
|
52
|
+
}),
|
|
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
|
+
z.strictObject({
|
|
65
|
+
event: z.literal('package_skipped'),
|
|
66
|
+
name: z.string(),
|
|
67
|
+
reason: z.string(),
|
|
68
|
+
}),
|
|
69
|
+
z.strictObject({
|
|
70
|
+
event: z.literal('package_completed'),
|
|
71
|
+
name: z.string(),
|
|
72
|
+
old_version: z.string(),
|
|
73
|
+
new_version: z.string(),
|
|
74
|
+
// mirrors `BumpType` from `semver.ts`; inline so the event schema is self-contained
|
|
75
|
+
bump_type: z.enum(['major', 'minor', 'patch']),
|
|
76
|
+
breaking: z.boolean(),
|
|
77
|
+
commit: z.string().meta({description: "'simulated' in a dry run, otherwise the commit hash"}),
|
|
78
|
+
tag: z.string(),
|
|
79
|
+
}),
|
|
80
|
+
z.strictObject({
|
|
81
|
+
event: z.literal('package_failed'),
|
|
82
|
+
name: z.string(),
|
|
83
|
+
error: z.string(),
|
|
84
|
+
code: PublishingErrorCode,
|
|
85
|
+
}),
|
|
86
|
+
z.strictObject({
|
|
87
|
+
event: z.literal('dependency_updated'),
|
|
88
|
+
dependent: z.string(),
|
|
89
|
+
dependency: z.string(),
|
|
90
|
+
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(),
|
|
104
|
+
}),
|
|
105
|
+
z.strictObject({
|
|
106
|
+
event: z.literal('deploy_started'),
|
|
107
|
+
name: z.string(),
|
|
108
|
+
}),
|
|
109
|
+
z.strictObject({
|
|
110
|
+
event: z.literal('deploy_completed'),
|
|
111
|
+
name: z.string(),
|
|
112
|
+
}),
|
|
113
|
+
z.strictObject({
|
|
114
|
+
event: z.literal('deploy_failed'),
|
|
115
|
+
name: z.string(),
|
|
116
|
+
error: z.string(),
|
|
117
|
+
}),
|
|
118
|
+
z.strictObject({
|
|
119
|
+
event: z.literal('run_finished'),
|
|
120
|
+
summary: PublishingRunSummary,
|
|
121
|
+
}),
|
|
122
|
+
]);
|
|
123
|
+
export type PublishingEvent = z.infer<typeof PublishingEvent>;
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Derives a run summary from the captured event list ā the single canonical path from
|
|
127
|
+
* events to summary, so the `run_finished` summary always agrees with the stream.
|
|
128
|
+
* Call before emitting `run_finished` (which is not itself counted).
|
|
129
|
+
*
|
|
130
|
+
* @param events - the events captured so far this run
|
|
131
|
+
* @param duration - wall-clock duration in milliseconds
|
|
132
|
+
*/
|
|
133
|
+
export const summarize_events = (
|
|
134
|
+
events: Array<PublishingEvent>,
|
|
135
|
+
duration: number,
|
|
136
|
+
): PublishingRunSummary => {
|
|
137
|
+
let total = 0;
|
|
138
|
+
let published = 0;
|
|
139
|
+
let failed = 0;
|
|
140
|
+
let skipped = 0;
|
|
141
|
+
for (const event of events) {
|
|
142
|
+
switch (event.event) {
|
|
143
|
+
case 'run_started':
|
|
144
|
+
total = event.total;
|
|
145
|
+
break;
|
|
146
|
+
case 'package_completed':
|
|
147
|
+
published++;
|
|
148
|
+
break;
|
|
149
|
+
case 'package_failed':
|
|
150
|
+
failed++;
|
|
151
|
+
break;
|
|
152
|
+
case 'package_skipped':
|
|
153
|
+
skipped++;
|
|
154
|
+
break;
|
|
155
|
+
default:
|
|
156
|
+
break;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return {total, published, failed, skipped, duration};
|
|
160
|
+
};
|
|
@@ -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
|
+
};
|
package/src/lib/repo_ops.ts
CHANGED
|
@@ -83,7 +83,7 @@ export interface RepoPath {
|
|
|
83
83
|
* Get repo paths from gitops config without full git sync.
|
|
84
84
|
* Lighter weight than `get_gitops_ready()` - just resolves paths.
|
|
85
85
|
*
|
|
86
|
-
* @param config_path - path to
|
|
86
|
+
* @param config_path - path to the gitops config file (defaults to `gitops.config.ts`)
|
|
87
87
|
* @returns array of repo info with name, path, and url
|
|
88
88
|
*/
|
|
89
89
|
export const get_repo_paths = async (config_path?: string): Promise<Array<RepoPath>> => {
|