@quenty/nevermore-cli 4.50.1 → 4.52.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 (116) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/dist/commands/batch-command/batch-bundle-command.d.ts +15 -0
  3. package/dist/commands/batch-command/batch-bundle-command.d.ts.map +1 -0
  4. package/dist/commands/batch-command/batch-bundle-command.js +168 -0
  5. package/dist/commands/batch-command/batch-bundle-command.js.map +1 -0
  6. package/dist/commands/batch-command/batch-deploy-command.d.ts +2 -0
  7. package/dist/commands/batch-command/batch-deploy-command.d.ts.map +1 -1
  8. package/dist/commands/batch-command/batch-deploy-command.js +115 -16
  9. package/dist/commands/batch-command/batch-deploy-command.js.map +1 -1
  10. package/dist/commands/batch-command/batch-test-command.d.ts +1 -0
  11. package/dist/commands/batch-command/batch-test-command.d.ts.map +1 -1
  12. package/dist/commands/batch-command/batch-test-command.js +109 -17
  13. package/dist/commands/batch-command/batch-test-command.js.map +1 -1
  14. package/dist/commands/batch-command/index.d.ts.map +1 -1
  15. package/dist/commands/batch-command/index.js +2 -0
  16. package/dist/commands/batch-command/index.js.map +1 -1
  17. package/dist/commands/deploy-command/index.d.ts.map +1 -1
  18. package/dist/commands/deploy-command/index.js +7 -0
  19. package/dist/commands/deploy-command/index.js.map +1 -1
  20. package/dist/commands/test-command/test-command.d.ts +1 -0
  21. package/dist/commands/test-command/test-command.d.ts.map +1 -1
  22. package/dist/commands/test-command/test-command.js +19 -3
  23. package/dist/commands/test-command/test-command.js.map +1 -1
  24. package/dist/commands/tools-command/ci-post-deploy-results.d.ts.map +1 -1
  25. package/dist/commands/tools-command/ci-post-deploy-results.js +6 -2
  26. package/dist/commands/tools-command/ci-post-deploy-results.js.map +1 -1
  27. package/dist/commands/tools-command/ci-post-test-results.d.ts +1 -0
  28. package/dist/commands/tools-command/ci-post-test-results.d.ts.map +1 -1
  29. package/dist/commands/tools-command/ci-post-test-results.js +12 -3
  30. package/dist/commands/tools-command/ci-post-test-results.js.map +1 -1
  31. package/dist/utils/batch/changed-packages-utils.d.ts +11 -0
  32. package/dist/utils/batch/changed-packages-utils.d.ts.map +1 -1
  33. package/dist/utils/batch/changed-packages-utils.js +14 -0
  34. package/dist/utils/batch/changed-packages-utils.js.map +1 -1
  35. package/dist/utils/build/build.d.ts +7 -0
  36. package/dist/utils/build/build.d.ts.map +1 -1
  37. package/dist/utils/build/build.js.map +1 -1
  38. package/dist/utils/bundle/untrusted-tar.d.ts +28 -0
  39. package/dist/utils/bundle/untrusted-tar.d.ts.map +1 -0
  40. package/dist/utils/bundle/untrusted-tar.js +116 -0
  41. package/dist/utils/bundle/untrusted-tar.js.map +1 -0
  42. package/dist/utils/deploy/deploy-github-columns.d.ts +8 -1
  43. package/dist/utils/deploy/deploy-github-columns.d.ts.map +1 -1
  44. package/dist/utils/deploy/deploy-github-columns.js.map +1 -1
  45. package/dist/utils/deploy/deploy-metadata.d.ts +5 -0
  46. package/dist/utils/deploy/deploy-metadata.d.ts.map +1 -1
  47. package/dist/utils/deploy/deploy-metadata.js +36 -0
  48. package/dist/utils/deploy/deploy-metadata.js.map +1 -1
  49. package/dist/utils/deploy/deploy-metadata.test.js +35 -2
  50. package/dist/utils/deploy/deploy-metadata.test.js.map +1 -1
  51. package/dist/utils/deploy/prebuilt-deploy-bundle.d.ts +38 -0
  52. package/dist/utils/deploy/prebuilt-deploy-bundle.d.ts.map +1 -0
  53. package/dist/utils/deploy/prebuilt-deploy-bundle.js +209 -0
  54. package/dist/utils/deploy/prebuilt-deploy-bundle.js.map +1 -0
  55. package/dist/utils/deploy/prebuilt-deploy-bundle.test.d.ts +2 -0
  56. package/dist/utils/deploy/prebuilt-deploy-bundle.test.d.ts.map +1 -0
  57. package/dist/utils/deploy/prebuilt-deploy-bundle.test.js +194 -0
  58. package/dist/utils/deploy/prebuilt-deploy-bundle.test.js.map +1 -0
  59. package/dist/utils/job-context/base-job-context.d.ts.map +1 -1
  60. package/dist/utils/job-context/base-job-context.js +3 -2
  61. package/dist/utils/job-context/base-job-context.js.map +1 -1
  62. package/dist/utils/job-context/batch-script-job-context.d.ts +12 -0
  63. package/dist/utils/job-context/batch-script-job-context.d.ts.map +1 -1
  64. package/dist/utils/job-context/batch-script-job-context.js +19 -0
  65. package/dist/utils/job-context/batch-script-job-context.js.map +1 -1
  66. package/dist/utils/open-cloud/open-cloud-client.d.ts +1 -0
  67. package/dist/utils/open-cloud/open-cloud-client.d.ts.map +1 -1
  68. package/dist/utils/open-cloud/open-cloud-client.js +5 -0
  69. package/dist/utils/open-cloud/open-cloud-client.js.map +1 -1
  70. package/dist/utils/open-cloud/open-cloud-client.test.js +11 -0
  71. package/dist/utils/open-cloud/open-cloud-client.test.js.map +1 -1
  72. package/dist/utils/sourcemap/sourcemap-loader.d.ts +3 -3
  73. package/dist/utils/sourcemap/sourcemap-loader.d.ts.map +1 -1
  74. package/dist/utils/sourcemap/sourcemap-loader.js +6 -7
  75. package/dist/utils/sourcemap/sourcemap-loader.js.map +1 -1
  76. package/dist/utils/testing/batch-run.e2e.test.js +36 -0
  77. package/dist/utils/testing/batch-run.e2e.test.js.map +1 -1
  78. package/dist/utils/testing/runner/prebuilt-batch-bundle.d.ts +26 -0
  79. package/dist/utils/testing/runner/prebuilt-batch-bundle.d.ts.map +1 -0
  80. package/dist/utils/testing/runner/prebuilt-batch-bundle.js +145 -0
  81. package/dist/utils/testing/runner/prebuilt-batch-bundle.js.map +1 -0
  82. package/dist/utils/testing/runner/prebuilt-batch-bundle.test.d.ts +2 -0
  83. package/dist/utils/testing/runner/prebuilt-batch-bundle.test.d.ts.map +1 -0
  84. package/dist/utils/testing/runner/prebuilt-batch-bundle.test.js +197 -0
  85. package/dist/utils/testing/runner/prebuilt-batch-bundle.test.js.map +1 -0
  86. package/dist/utils/testing/runner/test-runner.d.ts +8 -1
  87. package/dist/utils/testing/runner/test-runner.d.ts.map +1 -1
  88. package/dist/utils/testing/runner/test-runner.js +19 -6
  89. package/dist/utils/testing/runner/test-runner.js.map +1 -1
  90. package/package.json +7 -6
  91. package/src/commands/batch-command/batch-bundle-command.ts +236 -0
  92. package/src/commands/batch-command/batch-deploy-command.ts +160 -16
  93. package/src/commands/batch-command/batch-test-command.ts +162 -18
  94. package/src/commands/batch-command/index.ts +2 -0
  95. package/src/commands/deploy-command/index.ts +7 -0
  96. package/src/commands/test-command/test-command.ts +20 -3
  97. package/src/commands/tools-command/ci-post-deploy-results.ts +9 -2
  98. package/src/commands/tools-command/ci-post-test-results.ts +20 -3
  99. package/src/utils/batch/changed-packages-utils.ts +23 -0
  100. package/src/utils/build/build.ts +7 -0
  101. package/src/utils/bundle/untrusted-tar.ts +160 -0
  102. package/src/utils/deploy/deploy-github-columns.ts +8 -1
  103. package/src/utils/deploy/deploy-metadata.test.ts +50 -1
  104. package/src/utils/deploy/deploy-metadata.ts +39 -0
  105. package/src/utils/deploy/prebuilt-deploy-bundle.test.ts +265 -0
  106. package/src/utils/deploy/prebuilt-deploy-bundle.ts +297 -0
  107. package/src/utils/job-context/base-job-context.ts +4 -2
  108. package/src/utils/job-context/batch-script-job-context.ts +34 -3
  109. package/src/utils/open-cloud/open-cloud-client.test.ts +15 -0
  110. package/src/utils/open-cloud/open-cloud-client.ts +11 -0
  111. package/src/utils/sourcemap/sourcemap-loader.ts +7 -9
  112. package/src/utils/testing/batch-run.e2e.test.ts +42 -0
  113. package/src/utils/testing/runner/prebuilt-batch-bundle.test.ts +272 -0
  114. package/src/utils/testing/runner/prebuilt-batch-bundle.ts +204 -0
  115. package/src/utils/testing/runner/test-runner.ts +43 -13
  116. package/tsconfig.tsbuildinfo +1 -1
@@ -11,6 +11,8 @@ import { getApiKeyAsync } from '@quenty/nevermore-cli-helpers';
11
11
  import { runBatchAsync } from '../../utils/batch/batch-runner.js';
12
12
  import { renderBatchOutcome } from '../../utils/testing/parsers/batch-log-renderer.js';
13
13
  import { emitDiagnostic } from '../../utils/testing/parsers/batch-log-parser.js';
14
+ import { type DeployTarget } from '@quenty/nevermore-deploy';
15
+ import { type BuildContext } from '@quenty/nevermore-template-helpers';
14
16
  import { createBasePlaceResolver } from '../../utils/build/base-place-resolver-factory.js';
15
17
  import {
16
18
  type JobContext,
@@ -37,9 +39,13 @@ import {
37
39
  createTestCommentConfig,
38
40
  } from '../../utils/testing/reporting/index.js';
39
41
  import {
42
+ executePreparedTestAsync,
40
43
  runSingleTestAsync,
44
+ type SingleTestOptions,
41
45
  type SingleTestResult,
42
46
  } from '../../utils/testing/runner/test-runner.js';
47
+ import { type CombinedPlaceChunk } from '../../utils/testing/runner/combined-project-generator.js';
48
+ import { readPrebuiltBatchBundleAsync } from '../../utils/testing/runner/prebuilt-batch-bundle.js';
43
49
  import { isCI } from '../../utils/nevermore-cli-utils.js';
44
50
 
45
51
  interface BatchTestArgs extends NevermoreGlobalArgs {
@@ -56,6 +62,7 @@ interface BatchTestArgs extends NevermoreGlobalArgs {
56
62
  batchUniverseId?: number;
57
63
  maxPlaceMb?: number;
58
64
  timeout?: number;
65
+ bundle?: string;
59
66
  }
60
67
 
61
68
  export const batchTestCommand: CommandModule<
@@ -129,6 +136,14 @@ export const batchTestCommand: CommandModule<
129
136
  'Max script execution time in seconds for the whole batch. Sent to the Open Cloud API so Roblox cancels server-side on overrun. Max 300s (API limit). (default: 300)',
130
137
  type: 'number',
131
138
  })
139
+ .option('bundle', {
140
+ describe:
141
+ 'Run the places in a *.nevermore.tests.tar written by "batch bundle --test" instead of finding ' +
142
+ 'and building packages. The bundle never chooses where it is uploaded: ' +
143
+ 'that is --batch-place-id/--batch-universe-id, or else the test place this ' +
144
+ 'checkout gives one of its packages.',
145
+ type: 'string',
146
+ })
132
147
  // Accepted only to fail with a useful message. A batch runs one shared
133
148
  // script across packages, so there is nothing for arbitrary Luau to mean
134
149
  // here — but yargs strict mode would otherwise reject it with a bare
@@ -137,14 +152,30 @@ export const batchTestCommand: CommandModule<
137
152
  hidden: true,
138
153
  type: 'string',
139
154
  })
155
+ .option('script-file', {
156
+ hidden: true,
157
+ type: 'string',
158
+ })
140
159
  .check((args) => {
141
- if (args.scriptText !== undefined) {
160
+ if (args.scriptText !== undefined || args.scriptFile !== undefined) {
142
161
  throw new Error(
143
- '--script-text is not supported by "batch test", which runs one shared script for all packages.\n' +
162
+ '--script-text and --script-file are not supported by "batch test", which runs one shared script for all packages.\n' +
144
163
  'Run it against a single package instead:\n' +
145
164
  ' cd <package> && nevermore test --cloud --script-text \'print("hi")\''
146
165
  );
147
166
  }
167
+ if (args.bundle !== undefined) {
168
+ if (args.all || args.limit !== undefined) {
169
+ throw new Error(
170
+ '--bundle runs every package in the bundle; drop --all and --limit.'
171
+ );
172
+ }
173
+ if (args.aggregated === false) {
174
+ throw new Error(
175
+ '--bundle runs an aggregated batch; drop --no-aggregated.'
176
+ );
177
+ }
178
+ }
148
179
  return true;
149
180
  })
150
181
  );
@@ -160,12 +191,38 @@ export const batchTestCommand: CommandModule<
160
191
  };
161
192
 
162
193
  async function _runAsync(args: BatchTestArgs): Promise<void> {
163
- let packages = args.all
164
- ? await discoverAllTestableBatchTargetsAsync()
165
- : await discoverChangedTestableBatchTargetsAsync(args.base!);
194
+ const prebuilt =
195
+ args.bundle !== undefined
196
+ ? await _loadPrebuiltRunAsync(args, args.bundle)
197
+ : undefined;
198
+ let exitCode: number | undefined;
199
+ try {
200
+ exitCode = await _runPackagesAsync(args, prebuilt);
201
+ } finally {
202
+ await prebuilt?.buildContext.cleanupAsync();
203
+ }
204
+
205
+ // Node.js fetch (undici) keeps TCP connections alive in a global pool,
206
+ // which prevents the event loop from draining. Explicit exit required.
207
+ if (exitCode !== undefined) {
208
+ process.exit(exitCode);
209
+ }
210
+ }
166
211
 
167
- if (args.limit && args.limit > 0) {
168
- packages = packages.slice(0, args.limit);
212
+ async function _runPackagesAsync(
213
+ args: BatchTestArgs,
214
+ prebuilt: PrebuiltRun | undefined
215
+ ): Promise<number | undefined> {
216
+ let packages: BatchTarget[];
217
+ if (prebuilt) {
218
+ packages = prebuilt.packages;
219
+ } else {
220
+ packages = args.all
221
+ ? await discoverAllTestableBatchTargetsAsync()
222
+ : await discoverChangedTestableBatchTargetsAsync(args.base!);
223
+ if (args.limit && args.limit > 0) {
224
+ packages = packages.slice(0, args.limit);
225
+ }
169
226
  }
170
227
 
171
228
  if (packages.length === 0) {
@@ -173,7 +230,7 @@ async function _runAsync(args: BatchTestArgs): Promise<void> {
173
230
  'No testable packages found. Packages need a deploy.nevermore.json with a "test" target.\n' +
174
231
  'Run "nevermore deploy init" inside a package to set one up.'
175
232
  );
176
- return;
233
+ return undefined;
177
234
  }
178
235
 
179
236
  if (args.dryrun) {
@@ -181,7 +238,7 @@ async function _runAsync(args: BatchTestArgs): Promise<void> {
181
238
  OutputHelper.info(
182
239
  `[DRYRUN] Would test ${packages.length} packages: ${names}`
183
240
  );
184
- return;
241
+ return undefined;
185
242
  }
186
243
 
187
244
  const cloud = args.cloud ?? false;
@@ -273,8 +330,8 @@ async function _runAsync(args: BatchTestArgs): Promise<void> {
273
330
 
274
331
  const batchContext = aggregated
275
332
  ? new BatchScriptJobContext(innerContext, packages, {
276
- batchPlaceId: args.batchPlaceId,
277
- batchUniverseId: args.batchUniverseId,
333
+ batchPlaceId: prebuilt?.target.placeId ?? args.batchPlaceId,
334
+ batchUniverseId: prebuilt?.target.universeId ?? args.batchUniverseId,
278
335
  batchTimeoutMs: timeoutMs,
279
336
  // Only an upload is size-limited, so a local run stays one place
280
337
  // unless asked to split.
@@ -285,6 +342,10 @@ async function _runAsync(args: BatchTestArgs): Promise<void> {
285
342
  ? undefined
286
343
  : Infinity,
287
344
  reporter,
345
+ prebuilt: prebuilt && {
346
+ chunks: prebuilt.chunks,
347
+ buildContext: prebuilt.buildContext,
348
+ },
288
349
  })
289
350
  : undefined;
290
351
  const context: JobContext = batchContext ?? innerContext;
@@ -326,7 +387,8 @@ async function _runAsync(args: BatchTestArgs): Promise<void> {
326
387
  pkg,
327
388
  context,
328
389
  timeoutMs,
329
- aggregated
390
+ aggregated,
391
+ prebuilt?.hasJestConfig.get(pkg.name)
330
392
  );
331
393
 
332
394
  return {
@@ -360,19 +422,17 @@ async function _runAsync(args: BatchTestArgs): Promise<void> {
360
422
  }
361
423
 
362
424
  await reporter.stopAsync();
363
-
364
- // Node.js fetch (undici) keeps TCP connections alive in a global pool,
365
- // which prevents the event loop from draining. Explicit exit required.
366
- process.exit(exitCode);
425
+ return exitCode;
367
426
  }
368
427
 
369
428
  async function _runWithRetryAsync(
370
429
  pkg: BatchTarget,
371
430
  context: JobContext,
372
431
  timeoutMs: number,
373
- aggregated: boolean
432
+ aggregated: boolean,
433
+ prebuiltHasJestConfig: boolean | undefined
374
434
  ): Promise<SingleTestResult> {
375
- const opts = {
435
+ const opts: SingleTestOptions = {
376
436
  packagePath: pkg.path,
377
437
  packageName: pkg.name,
378
438
  timeoutMs,
@@ -382,11 +442,95 @@ async function _runWithRetryAsync(
382
442
  suppressCountsProvenance: aggregated,
383
443
  };
384
444
 
445
+ if (prebuiltHasJestConfig !== undefined) {
446
+ return _retryOnTransientAsync(pkg.name, async () =>
447
+ executePreparedTestAsync(
448
+ context,
449
+ {
450
+ builtPlace: await context.buildPlaceAsync(opts),
451
+ scriptContent: '',
452
+ hasJestConfig: prebuiltHasJestConfig,
453
+ },
454
+ opts
455
+ )
456
+ );
457
+ }
458
+
385
459
  return _retryOnTransientAsync(pkg.name, () =>
386
460
  runSingleTestAsync(context, opts)
387
461
  );
388
462
  }
389
463
 
464
+ interface PrebuiltRun {
465
+ target: DeployTarget;
466
+ packages: BatchTarget[];
467
+ chunks: CombinedPlaceChunk[];
468
+ hasJestConfig: Map<string, boolean>;
469
+ buildContext: BuildContext;
470
+ }
471
+
472
+ async function _loadPrebuiltRunAsync(
473
+ args: BatchTestArgs,
474
+ bundleFile: string
475
+ ): Promise<PrebuiltRun> {
476
+ const bundle = await readPrebuiltBatchBundleAsync(bundleFile);
477
+ let target: DeployTarget;
478
+ try {
479
+ target = await _resolvePrebuiltTargetAsync(
480
+ args,
481
+ new Set(bundle.packages.map((p) => p.name))
482
+ );
483
+ } catch (err) {
484
+ await bundle.buildContext.cleanupAsync();
485
+ throw err;
486
+ }
487
+
488
+ OutputHelper.info(
489
+ `Running ${bundle.packages.length} prebuilt package${
490
+ bundle.packages.length === 1 ? '' : 's'
491
+ } in ${bundle.chunks.length} place${
492
+ bundle.chunks.length === 1 ? '' : 's'
493
+ } on place ${target.placeId}.`
494
+ );
495
+
496
+ return {
497
+ target,
498
+ packages: bundle.packages.map((p) => ({
499
+ name: p.name,
500
+ packageName: p.name,
501
+ path: process.cwd(),
502
+ target,
503
+ })),
504
+ chunks: bundle.chunks,
505
+ hasJestConfig: new Map(
506
+ bundle.packages.map((p) => [p.name, p.hasJestConfig])
507
+ ),
508
+ buildContext: bundle.buildContext,
509
+ };
510
+ }
511
+
512
+ /**
513
+ * Where a bundle uploads to, picked the way a normal batch run picks it: the
514
+ * test place of one of its packages, read from this checkout rather than from
515
+ * the bundle.
516
+ */
517
+ async function _resolvePrebuiltTargetAsync(
518
+ args: BatchTestArgs,
519
+ bundledNames: Set<string>
520
+ ): Promise<DeployTarget> {
521
+ const known = await discoverAllTestableBatchTargetsAsync();
522
+ const base = known.find((t) => bundledNames.has(t.name)) ?? known[0];
523
+ const universeId = args.batchUniverseId ?? base?.target.universeId;
524
+ const placeId = args.batchPlaceId ?? base?.target.placeId;
525
+ if (!universeId || !placeId) {
526
+ throw new Error(
527
+ 'No test place to upload the bundle to: this checkout has no testable ' +
528
+ 'package. Pass --batch-place-id and --batch-universe-id.'
529
+ );
530
+ }
531
+ return { universeId, placeId, project: '' };
532
+ }
533
+
390
534
  /**
391
535
  * Run something once more if it failed for a reason that tends not to repeat.
392
536
  *
@@ -1,5 +1,6 @@
1
1
  import { Argv, CommandModule } from 'yargs';
2
2
  import { NevermoreGlobalArgs } from '../../args/global-args.js';
3
+ import { batchBundleCommand } from './batch-bundle-command.js';
3
4
  import { batchDeployCommand } from './batch-deploy-command.js';
4
5
  import { batchTestCommand } from './batch-test-command.js';
5
6
 
@@ -8,6 +9,7 @@ export class BatchCommand<T> implements CommandModule<T, NevermoreGlobalArgs> {
8
9
  public describe = 'Run operations across multiple packages';
9
10
 
10
11
  public builder = (args: Argv<T>) => {
12
+ args.command(batchBundleCommand as any);
11
13
  args.command(batchDeployCommand as any);
12
14
  args.command(batchTestCommand as any);
13
15
  return args as Argv<NevermoreGlobalArgs>;
@@ -711,6 +711,13 @@ export class DeployCommand<T> implements CommandModule<T, DeployArgs> {
711
711
  return {
712
712
  packageName: buildTarget.name,
713
713
  placeId: uploadedTarget.placeId,
714
+ universeId: uploadedTarget.universeId,
715
+ target: targetName,
716
+ version,
717
+ published: publish,
718
+ basePlaceVersion: builtPlace.basePlaceVersion,
719
+ commit: deployGitInfo.version,
720
+ branch: deployGitInfo.branch,
714
721
  success: true,
715
722
  logs: `${action} v${version}`,
716
723
  progressSummary: {
@@ -1,3 +1,4 @@
1
+ import * as fs from 'fs/promises';
1
2
  import * as path from 'path';
2
3
  import { Argv, CommandModule } from 'yargs';
3
4
  import { OutputHelper } from '@quenty/cli-output-helpers';
@@ -36,6 +37,7 @@ export interface TestProjectArgs extends NevermoreGlobalArgs {
36
37
  placeId?: number;
37
38
  scriptTemplate?: string;
38
39
  scriptText?: string;
40
+ scriptFile?: string;
39
41
  output?: string;
40
42
  timeout?: number;
41
43
  }
@@ -58,7 +60,7 @@ export class TestProjectCommand<T>
58
60
  });
59
61
  args.option('logs', {
60
62
  describe:
61
- 'Show execution logs. Defaults on with --script-text, whose output is the whole point of the run',
63
+ 'Show execution logs. Defaults on with --script-text and --script-file, whose output is the whole point of the run',
62
64
  type: 'boolean',
63
65
  });
64
66
  args.option('universe-id', {
@@ -78,6 +80,12 @@ export class TestProjectCommand<T>
78
80
  describe:
79
81
  'Luau code to execute directly instead of the configured script template',
80
82
  type: 'string',
83
+ conflicts: 'script-file',
84
+ });
85
+ args.option('script-file', {
86
+ describe:
87
+ 'Path to a Luau file to execute directly, like --script-text but without the command line length limit',
88
+ type: 'string',
81
89
  });
82
90
  args.option('output', {
83
91
  describe: 'Write JSON results to this file',
@@ -98,7 +106,16 @@ export class TestProjectCommand<T>
98
106
  // A probe run exists to show its output. Hiding it behind an opt-in flag
99
107
  // made --script-text look broken enough that a workaround was written into
100
108
  // the docs instead of a bug report.
101
- const showLogs = args.logs ?? args.scriptText !== undefined;
109
+ let scriptText = args.scriptText;
110
+ if (args.scriptFile !== undefined) {
111
+ try {
112
+ scriptText = await fs.readFile(args.scriptFile, 'utf-8');
113
+ } catch {
114
+ OutputHelper.error(`Script file not found: ${args.scriptFile}`);
115
+ process.exit(1);
116
+ }
117
+ }
118
+ const showLogs = args.logs ?? scriptText !== undefined;
102
119
  const useSpinner = process.stdout.isTTY && !args.verbose;
103
120
 
104
121
  const reporter = new CompositeReporter(
@@ -168,7 +185,7 @@ export class TestProjectCommand<T>
168
185
  placeId: args.placeId,
169
186
  scriptTemplate: args.scriptTemplate,
170
187
  },
171
- scriptText: args.scriptText,
188
+ scriptText,
172
189
  timeoutMs:
173
190
  args.timeout !== undefined ? args.timeout * 1000 : undefined,
174
191
  });
@@ -49,8 +49,8 @@ export const ciPostDeployResultsCommand: CommandModule<
49
49
  .option('run-outcome', {
50
50
  describe:
51
51
  'Outcome of the deploy step (e.g. "success", "failure"). ' +
52
- 'When the results file is missing and run-outcome is "success", ' +
53
- 'posts a neutral "no deploys" comment instead of an error.',
52
+ 'When the results file is missing, "success" posts a neutral comment, ' +
53
+ '"cancelled" posts nothing, and anything else posts a failure.',
54
54
  type: 'string',
55
55
  });
56
56
  },
@@ -62,6 +62,13 @@ export const ciPostDeployResultsCommand: CommandModule<
62
62
  } catch {
63
63
  OutputHelper.warn(`Results file not found: ${args.input}`);
64
64
 
65
+ if (args.runOutcome === 'cancelled') {
66
+ OutputHelper.info(
67
+ 'Deploy step was cancelled; leaving the PR comment to the run that replaced it.'
68
+ );
69
+ return;
70
+ }
71
+
65
72
  const reporters = _createGithubReporters(undefined);
66
73
 
67
74
  if (args.runOutcome === 'success') {
@@ -27,6 +27,7 @@ type ErrorReporter = Reporter & {
27
27
  interface CiPostTestResultsArgs extends NevermoreGlobalArgs {
28
28
  input: string;
29
29
  runOutcome?: string;
30
+ sourcemap?: string;
30
31
  }
31
32
 
32
33
  /** Create the standard set of GitHub reporters for posting results. */
@@ -58,8 +59,14 @@ export const ciPostTestResultsCommand: CommandModule<
58
59
  .option('run-outcome', {
59
60
  describe:
60
61
  'Outcome of the test step (e.g. "success", "failure"). ' +
61
- 'When the results file is missing and run-outcome is "success", ' +
62
- 'posts a neutral "no tests" comment instead of an error.',
62
+ 'When the results file is missing, "success" posts a neutral comment, ' +
63
+ '"cancelled" posts nothing, and anything else posts a failure.',
64
+ type: 'string',
65
+ })
66
+ .option('sourcemap', {
67
+ describe:
68
+ 'Sourcemap that test failure annotations resolve files against ' +
69
+ '(default: sourcemap.json in the current directory)',
63
70
  type: 'string',
64
71
  });
65
72
  },
@@ -71,6 +78,13 @@ export const ciPostTestResultsCommand: CommandModule<
71
78
  } catch {
72
79
  OutputHelper.warn(`Results file not found: ${args.input}`);
73
80
 
81
+ if (args.runOutcome === 'cancelled') {
82
+ OutputHelper.info(
83
+ 'Test step was cancelled; leaving the PR comment to the run that replaced it.'
84
+ );
85
+ return;
86
+ }
87
+
74
88
  const reporters = _createGithubReporters(undefined);
75
89
 
76
90
  if (args.runOutcome === 'success') {
@@ -100,7 +114,10 @@ export const ciPostTestResultsCommand: CommandModule<
100
114
  }
101
115
 
102
116
  // Emit test failure annotations
103
- const sourcemapResolver = tryLoadSourcemapResolver(process.cwd());
117
+ const sourcemapResolver = tryLoadSourcemapResolver(
118
+ process.cwd(),
119
+ args.sourcemap
120
+ );
104
121
  const workspace = await tryLoadAnnotationWorkspaceAsync(process.cwd());
105
122
  const parsedDiagnostics: Diagnostic[] = [];
106
123
  for (const failure of state.getFailures()) {
@@ -126,6 +126,29 @@ export async function discoverChangedTestableBatchTargetsAsync(
126
126
  return _requireScriptTemplate(flattenToBatchTargets(packages));
127
127
  }
128
128
 
129
+ /**
130
+ * The places `batch deploy` deploys, and `batch bundle --deploy` builds for a
131
+ * later deploy. One function so the two can never pick different places: a
132
+ * fork's bundle must hold what a same-repo run would have deployed.
133
+ */
134
+ export async function discoverDeployBatchTargetsAsync(options: {
135
+ targetName: string;
136
+ all?: boolean;
137
+ base: string;
138
+ limit?: number;
139
+ }): Promise<BatchTarget[]> {
140
+ const discovered = options.all
141
+ ? await discoverAllTargetPackagesAsync(options.targetName)
142
+ : await discoverChangedTargetPackagesAsync(
143
+ options.base,
144
+ options.targetName
145
+ );
146
+ const batchTargets = flattenToBatchTargets(discovered);
147
+ return options.limit && options.limit > 0
148
+ ? batchTargets.slice(0, options.limit)
149
+ : batchTargets;
150
+ }
151
+
129
152
  /**
130
153
  * Fan a list of TargetPackages out into BatchTargets — one per place. A
131
154
  * single-place package produces one BatchTarget with the package name; a
@@ -10,6 +10,12 @@ export interface DeployOverrides {
10
10
  placeId?: number;
11
11
  scriptTemplate?: string;
12
12
  placeFile?: string;
13
+ /**
14
+ * Rojo project the base place merge grafts by, in place of the target's own.
15
+ * Set together with `placeFile` when the place was built from a different
16
+ * checkout, so the merge follows the project that built it.
17
+ */
18
+ projectFile?: string;
13
19
  }
14
20
 
15
21
  export interface BuildPlaceOptions {
@@ -35,6 +41,7 @@ export interface BuildPlaceOptions {
35
41
  overrides?: DeployOverrides;
36
42
  reporter?: Reporter;
37
43
  packageName?: string;
44
+ deferBasePlaceMerge?: boolean;
38
45
  }
39
46
 
40
47
  /**
@@ -0,0 +1,160 @@
1
+ import { createReadStream, createWriteStream } from 'fs';
2
+ import * as path from 'path';
3
+ import * as tar from 'tar';
4
+
5
+ // Names reach the bot's PR comment, so a fork's bundle gets no Markdown links,
6
+ // images, URLs, tables or @mentions: only a leading scope `@`, and plain text
7
+ // for a multi-place target's place name ("pkg - Hub (Test)").
8
+ export const PACKAGE_NAME_PATTERN = /^@?[\p{L}\p{N} /()+.,'_~-]{1,200}$/u;
9
+
10
+ export type BundleKind = 'tests' | 'deploy';
11
+
12
+ const RUN_COMMANDS: Record<BundleKind, string> = {
13
+ tests: 'nevermore batch test --bundle <file>',
14
+ deploy: 'nevermore batch deploy --bundle <file>',
15
+ };
16
+
17
+ export interface UntrustedTarOptions {
18
+ kind: BundleKind;
19
+ /** The one entry read into memory rather than written to `dir`. */
20
+ manifestName: string;
21
+ maxManifestBytes: number;
22
+ /** Every other entry must pass this, by its exact path in the archive. */
23
+ isEntryAllowed: (name: string) => boolean;
24
+ maxEntries: number;
25
+ /** Message for an archive holding more than `maxEntries` entries. */
26
+ maxEntriesMessage: string;
27
+ /** Returns an error message when `size` is too large for this entry. */
28
+ checkEntrySize: (name: string, size: number) => string | undefined;
29
+ }
30
+
31
+ export interface UntrustedTarContents {
32
+ manifestText: string;
33
+ /** Size of each entry written into `dir`, keyed by its archive path. */
34
+ entrySizes: Map<string, number>;
35
+ }
36
+
37
+ /**
38
+ * Read a bundle written by a fork's CI run into `dir`. Entries are read from the
39
+ * archive, never extracted by it: only regular files whose paths match
40
+ * `entryPattern` (or are the manifest) are accepted, so a path can never leave
41
+ * `dir` and a link can never be followed.
42
+ */
43
+ export async function readUntrustedTarAsync(
44
+ file: string,
45
+ dir: string,
46
+ options: UntrustedTarOptions
47
+ ): Promise<UntrustedTarContents> {
48
+ let manifestText: string | undefined;
49
+ const entrySizes = new Map<string, number>();
50
+ const writes: Promise<void>[] = [];
51
+ let failure: Error | undefined;
52
+ // Write errors are recorded rather than rejected: the promises are awaited
53
+ // only once parsing ends, and one rejecting before then is unhandled.
54
+ const fail = (err: Error) => {
55
+ failure ??= err;
56
+ };
57
+
58
+ const parser = new tar.Parser({
59
+ strict: true,
60
+ onReadEntry: (entry) => {
61
+ const name = entry.path;
62
+ const reject = (message: string) => {
63
+ failure ??= new Error(message);
64
+ entry.resume();
65
+ };
66
+
67
+ if (entry.type !== 'File' && entry.type !== 'OldFile') {
68
+ return reject(`Bundle entry ${name} is not a regular file`);
69
+ }
70
+
71
+ if (name === options.manifestName) {
72
+ if (
73
+ manifestText !== undefined ||
74
+ entry.size > options.maxManifestBytes
75
+ ) {
76
+ return reject('Bundle has more than one manifest, or one too large');
77
+ }
78
+ const parts: Buffer[] = [];
79
+ entry.on('data', (part: Buffer) => parts.push(part));
80
+ entry.on('end', () => {
81
+ manifestText = Buffer.concat(parts).toString('utf-8');
82
+ });
83
+ return;
84
+ }
85
+
86
+ if (!options.isEntryAllowed(name) || entrySizes.has(name)) {
87
+ return reject(`Bundle has an unexpected entry: ${name}`);
88
+ }
89
+ if (entrySizes.size >= options.maxEntries) {
90
+ return reject(options.maxEntriesMessage);
91
+ }
92
+ const sizeError = options.checkEntrySize(name, entry.size);
93
+ if (sizeError) {
94
+ return reject(sizeError);
95
+ }
96
+ entrySizes.set(name, entry.size);
97
+ writes.push(
98
+ new Promise<void>((resolve) => {
99
+ const done = (err?: Error) => {
100
+ if (err) {
101
+ fail(err);
102
+ }
103
+ resolve();
104
+ };
105
+ const out = createWriteStream(path.join(dir, name));
106
+ out.on('finish', () => done());
107
+ out.on('error', (err) => {
108
+ done(err);
109
+ entry.resume();
110
+ });
111
+ entry.on('error', (err: unknown) =>
112
+ done(err instanceof Error ? err : new Error(String(err)))
113
+ );
114
+ entry.pipe(out);
115
+ })
116
+ );
117
+ },
118
+ });
119
+
120
+ await new Promise<void>((resolve, rejectRead) => {
121
+ const input = createReadStream(file);
122
+ input.on('error', rejectRead);
123
+ parser.on('error', (err: Error) =>
124
+ rejectRead(new Error(`Bundle is not a valid tar file: ${err.message}`))
125
+ );
126
+ parser.on('end', () => resolve());
127
+ input.pipe(parser);
128
+ });
129
+ await Promise.all(writes);
130
+
131
+ _checkKind(manifestText, options.kind);
132
+ if (failure) {
133
+ throw failure;
134
+ }
135
+ if (manifestText === undefined) {
136
+ throw new Error(`Bundle is missing ${options.manifestName}`);
137
+ }
138
+ return { manifestText, entrySizes };
139
+ }
140
+
141
+ function _checkKind(manifestText: string | undefined, expected: BundleKind) {
142
+ if (manifestText === undefined) {
143
+ return;
144
+ }
145
+ let kind: unknown;
146
+ try {
147
+ kind = (JSON.parse(manifestText) as { kind?: unknown })?.kind;
148
+ } catch {
149
+ return;
150
+ }
151
+ if (kind === undefined || kind === expected) {
152
+ return;
153
+ }
154
+ if (kind === 'tests' || kind === 'deploy') {
155
+ throw new Error(
156
+ `This is a ${kind} bundle, not a ${expected} bundle. Run it with "${RUN_COMMANDS[kind]}".`
157
+ );
158
+ }
159
+ throw new Error(`Bundle is of an unknown kind (expected ${expected})`);
160
+ }
@@ -6,9 +6,16 @@ import {
6
6
  summarizeError,
7
7
  } from '@quenty/cli-output-helpers/reporting';
8
8
 
9
- /** Deploy-specific result that extends the generic PackageResult with placeId. */
9
+ /** Deploy-specific result that extends the generic PackageResult with what was deployed where. */
10
10
  export interface BatchDeployResult extends PackageResult {
11
11
  placeId: number;
12
+ universeId?: number;
13
+ target?: string;
14
+ version?: number;
15
+ published?: boolean;
16
+ basePlaceVersion?: number;
17
+ commit?: string;
18
+ branch?: string;
12
19
  }
13
20
 
14
21
  /**