@quenty/nevermore-cli 4.51.0 → 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 (92) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/commands/batch-command/batch-bundle-command.d.ts +3 -0
  3. package/dist/commands/batch-command/batch-bundle-command.d.ts.map +1 -1
  4. package/dist/commands/batch-command/batch-bundle-command.js +84 -12
  5. package/dist/commands/batch-command/batch-bundle-command.js.map +1 -1
  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.map +1 -1
  11. package/dist/commands/batch-command/batch-test-command.js +7 -3
  12. package/dist/commands/batch-command/batch-test-command.js.map +1 -1
  13. package/dist/commands/deploy-command/index.d.ts.map +1 -1
  14. package/dist/commands/deploy-command/index.js +7 -0
  15. package/dist/commands/deploy-command/index.js.map +1 -1
  16. package/dist/commands/test-command/test-command.d.ts +1 -0
  17. package/dist/commands/test-command/test-command.d.ts.map +1 -1
  18. package/dist/commands/test-command/test-command.js +19 -3
  19. package/dist/commands/test-command/test-command.js.map +1 -1
  20. package/dist/commands/tools-command/ci-post-deploy-results.d.ts.map +1 -1
  21. package/dist/commands/tools-command/ci-post-deploy-results.js +6 -2
  22. package/dist/commands/tools-command/ci-post-deploy-results.js.map +1 -1
  23. package/dist/commands/tools-command/ci-post-test-results.d.ts.map +1 -1
  24. package/dist/commands/tools-command/ci-post-test-results.js +6 -2
  25. package/dist/commands/tools-command/ci-post-test-results.js.map +1 -1
  26. package/dist/utils/batch/changed-packages-utils.d.ts +11 -0
  27. package/dist/utils/batch/changed-packages-utils.d.ts.map +1 -1
  28. package/dist/utils/batch/changed-packages-utils.js +14 -0
  29. package/dist/utils/batch/changed-packages-utils.js.map +1 -1
  30. package/dist/utils/build/build.d.ts +7 -0
  31. package/dist/utils/build/build.d.ts.map +1 -1
  32. package/dist/utils/build/build.js.map +1 -1
  33. package/dist/utils/bundle/untrusted-tar.d.ts +28 -0
  34. package/dist/utils/bundle/untrusted-tar.d.ts.map +1 -0
  35. package/dist/utils/bundle/untrusted-tar.js +116 -0
  36. package/dist/utils/bundle/untrusted-tar.js.map +1 -0
  37. package/dist/utils/deploy/deploy-github-columns.d.ts +8 -1
  38. package/dist/utils/deploy/deploy-github-columns.d.ts.map +1 -1
  39. package/dist/utils/deploy/deploy-github-columns.js.map +1 -1
  40. package/dist/utils/deploy/deploy-metadata.d.ts +5 -0
  41. package/dist/utils/deploy/deploy-metadata.d.ts.map +1 -1
  42. package/dist/utils/deploy/deploy-metadata.js +36 -0
  43. package/dist/utils/deploy/deploy-metadata.js.map +1 -1
  44. package/dist/utils/deploy/deploy-metadata.test.js +35 -2
  45. package/dist/utils/deploy/deploy-metadata.test.js.map +1 -1
  46. package/dist/utils/deploy/prebuilt-deploy-bundle.d.ts +38 -0
  47. package/dist/utils/deploy/prebuilt-deploy-bundle.d.ts.map +1 -0
  48. package/dist/utils/deploy/prebuilt-deploy-bundle.js +209 -0
  49. package/dist/utils/deploy/prebuilt-deploy-bundle.js.map +1 -0
  50. package/dist/utils/deploy/prebuilt-deploy-bundle.test.d.ts +2 -0
  51. package/dist/utils/deploy/prebuilt-deploy-bundle.test.d.ts.map +1 -0
  52. package/dist/utils/deploy/prebuilt-deploy-bundle.test.js +194 -0
  53. package/dist/utils/deploy/prebuilt-deploy-bundle.test.js.map +1 -0
  54. package/dist/utils/job-context/base-job-context.d.ts.map +1 -1
  55. package/dist/utils/job-context/base-job-context.js +3 -2
  56. package/dist/utils/job-context/base-job-context.js.map +1 -1
  57. package/dist/utils/job-context/batch-script-job-context.d.ts +1 -1
  58. package/dist/utils/open-cloud/open-cloud-client.d.ts +1 -0
  59. package/dist/utils/open-cloud/open-cloud-client.d.ts.map +1 -1
  60. package/dist/utils/open-cloud/open-cloud-client.js +5 -0
  61. package/dist/utils/open-cloud/open-cloud-client.js.map +1 -1
  62. package/dist/utils/open-cloud/open-cloud-client.test.js +11 -0
  63. package/dist/utils/open-cloud/open-cloud-client.test.js.map +1 -1
  64. package/dist/utils/testing/runner/prebuilt-batch-bundle.d.ts +1 -1
  65. package/dist/utils/testing/runner/prebuilt-batch-bundle.d.ts.map +1 -1
  66. package/dist/utils/testing/runner/prebuilt-batch-bundle.js +13 -68
  67. package/dist/utils/testing/runner/prebuilt-batch-bundle.js.map +1 -1
  68. package/dist/utils/testing/runner/prebuilt-batch-bundle.test.js +16 -0
  69. package/dist/utils/testing/runner/prebuilt-batch-bundle.test.js.map +1 -1
  70. package/package.json +3 -3
  71. package/src/commands/batch-command/batch-bundle-command.ts +112 -16
  72. package/src/commands/batch-command/batch-deploy-command.ts +160 -16
  73. package/src/commands/batch-command/batch-test-command.ts +7 -3
  74. package/src/commands/deploy-command/index.ts +7 -0
  75. package/src/commands/test-command/test-command.ts +20 -3
  76. package/src/commands/tools-command/ci-post-deploy-results.ts +9 -2
  77. package/src/commands/tools-command/ci-post-test-results.ts +9 -2
  78. package/src/utils/batch/changed-packages-utils.ts +23 -0
  79. package/src/utils/build/build.ts +7 -0
  80. package/src/utils/bundle/untrusted-tar.ts +160 -0
  81. package/src/utils/deploy/deploy-github-columns.ts +8 -1
  82. package/src/utils/deploy/deploy-metadata.test.ts +50 -1
  83. package/src/utils/deploy/deploy-metadata.ts +39 -0
  84. package/src/utils/deploy/prebuilt-deploy-bundle.test.ts +265 -0
  85. package/src/utils/deploy/prebuilt-deploy-bundle.ts +297 -0
  86. package/src/utils/job-context/base-job-context.ts +4 -2
  87. package/src/utils/job-context/batch-script-job-context.ts +1 -1
  88. package/src/utils/open-cloud/open-cloud-client.test.ts +15 -0
  89. package/src/utils/open-cloud/open-cloud-client.ts +11 -0
  90. package/src/utils/testing/runner/prebuilt-batch-bundle.test.ts +20 -0
  91. package/src/utils/testing/runner/prebuilt-batch-bundle.ts +20 -83
  92. package/tsconfig.tsbuildinfo +1 -1
@@ -24,7 +24,13 @@ import {
24
24
  buildDeployMetadataAttributes,
25
25
  gatherGitDeployInfo,
26
26
  injectDeployMetadataAsync,
27
+ type GitDeployInfo,
27
28
  } from '../../utils/deploy/deploy-metadata.js';
29
+ import {
30
+ type PrebuiltDeployBundle,
31
+ type PrebuiltDeployPlace,
32
+ readPrebuiltDeployBundleAsync,
33
+ } from '../../utils/deploy/prebuilt-deploy-bundle.js';
28
34
  import { OpenCloudClient } from '../../utils/open-cloud/open-cloud-client.js';
29
35
  import {
30
36
  RateLimiter,
@@ -38,7 +44,7 @@ import {
38
44
  import {
39
45
  type BatchTarget,
40
46
  discoverAllTargetPackagesAsync,
41
- discoverChangedTargetPackagesAsync,
47
+ discoverDeployBatchTargetsAsync,
42
48
  flattenToBatchTargets,
43
49
  } from '../../utils/batch/changed-packages-utils.js';
44
50
  import {
@@ -77,6 +83,8 @@ interface BatchDeployArgs extends NevermoreGlobalArgs {
77
83
  watch?: string;
78
84
  watchShareApiKey?: boolean;
79
85
  watchUseGhAuth?: boolean;
86
+ bundle?: string;
87
+ bundleCommit?: string;
80
88
  }
81
89
 
82
90
  export const batchDeployCommand: CommandModule<
@@ -157,6 +165,36 @@ export const batchDeployCommand: CommandModule<
157
165
  'Off by default — the key is stored by the service.',
158
166
  type: 'boolean',
159
167
  default: false,
168
+ })
169
+ .option('bundle', {
170
+ describe:
171
+ 'Deploy the places in a *.nevermore.deploy.tar written by "batch bundle --deploy" instead of ' +
172
+ 'finding and building packages. The bundle only names places: where each one deploys, ' +
173
+ "and the base place it merges onto, come from this checkout's deploy.nevermore.json.",
174
+ type: 'string',
175
+ })
176
+ .option('bundle-commit', {
177
+ describe:
178
+ 'Fail unless the --bundle was built from this full commit sha, e.g. the PR head GitHub reports',
179
+ type: 'string',
180
+ })
181
+ .check((args) => {
182
+ if (args.bundleCommit !== undefined && args.bundle === undefined) {
183
+ throw new Error('--bundle-commit checks a --bundle; pass one.');
184
+ }
185
+ if (args.bundle !== undefined) {
186
+ if (args.all || args.limit !== undefined) {
187
+ throw new Error(
188
+ '--bundle deploys every place in the bundle; drop --all and --limit.'
189
+ );
190
+ }
191
+ if (args.watch !== undefined) {
192
+ throw new Error(
193
+ "--bundle deploys another checkout's build, which must not become a watch baseline; drop --watch."
194
+ );
195
+ }
196
+ }
197
+ return true;
160
198
  });
161
199
  },
162
200
  handler: async (args) => {
@@ -170,18 +208,58 @@ export const batchDeployCommand: CommandModule<
170
208
  };
171
209
 
172
210
  async function _runAsync(args: BatchDeployArgs): Promise<void> {
211
+ const bundle =
212
+ args.bundle !== undefined
213
+ ? await readPrebuiltDeployBundleAsync(args.bundle)
214
+ : undefined;
215
+ let exitCode: number;
216
+ try {
217
+ exitCode = await _runDeployAsync(args, bundle);
218
+ } finally {
219
+ await bundle?.buildContext.cleanupAsync();
220
+ }
221
+ process.exit(exitCode);
222
+ }
223
+
224
+ async function _runDeployAsync(
225
+ args: BatchDeployArgs,
226
+ bundle: PrebuiltDeployBundle | undefined
227
+ ): Promise<number> {
173
228
  const targetName = args.target ?? 'test';
174
229
  // Parsed up front so a typo'd duration fails before anything is deployed.
175
230
  const watchOption =
176
231
  args.watch == null ? undefined : parseWatchOption(args.watch);
177
232
 
178
- const discovered = args.all
179
- ? await discoverAllTargetPackagesAsync(targetName)
180
- : await discoverChangedTargetPackagesAsync(args.base!, targetName);
181
- let batchTargets = flattenToBatchTargets(discovered);
233
+ let batchTargets: BatchTarget[];
234
+ let prebuiltByName: Map<string, PrebuiltDeployPlace> | undefined;
235
+ if (
236
+ bundle &&
237
+ args.bundleCommit !== undefined &&
238
+ bundle.git.version !== args.bundleCommit
239
+ ) {
240
+ throw new Error(
241
+ `The bundle was built from ${bundle.git.version}, not ${args.bundleCommit}.`
242
+ );
243
+ }
244
+ if (bundle) {
245
+ ({ batchTargets, prebuiltByName } = await _resolveBundleTargetsAsync(
246
+ bundle,
247
+ targetName
248
+ ));
249
+ } else {
250
+ batchTargets = await discoverDeployBatchTargetsAsync({
251
+ targetName,
252
+ all: args.all,
253
+ base: args.base!,
254
+ limit: args.limit,
255
+ });
256
+ }
182
257
 
183
- if (args.limit && args.limit > 0) {
184
- batchTargets = batchTargets.slice(0, args.limit);
258
+ if (batchTargets.length === 0 && bundle) {
259
+ OutputHelper.warn(
260
+ `The bundle holds no places; nothing changed in a "${targetName}" target.`
261
+ );
262
+ return 0;
185
263
  }
186
264
 
187
265
  if (batchTargets.length === 0) {
@@ -217,7 +295,7 @@ async function _runAsync(args: BatchDeployArgs): Promise<void> {
217
295
  dryrun: args.dryrun,
218
296
  });
219
297
  }
220
- return;
298
+ return 0;
221
299
  }
222
300
 
223
301
  if (args.dryrun) {
@@ -247,7 +325,7 @@ async function _runAsync(args: BatchDeployArgs): Promise<void> {
247
325
  dryrun: true,
248
326
  });
249
327
  }
250
- return;
328
+ return 0;
251
329
  }
252
330
 
253
331
  const concurrency = args.concurrency ?? 3;
@@ -306,7 +384,8 @@ async function _runAsync(args: BatchDeployArgs): Promise<void> {
306
384
  const context = new CloudJobContext(reporter, client, basePlaceResolver);
307
385
 
308
386
  // Gathered once so every deployed package shares one timestamp and commit.
309
- const deployGitInfo = gatherGitDeployInfo();
387
+ // A bundle's places carry the commit that built them, not this checkout's.
388
+ const deployGitInfo: GitDeployInfo = bundle?.git ?? gatherGitDeployInfo();
310
389
  const deployTimestamp = new Date().toISOString();
311
390
 
312
391
  await reporter.startAsync();
@@ -319,11 +398,18 @@ async function _runAsync(args: BatchDeployArgs): Promise<void> {
319
398
  reporter,
320
399
  bufferOutput: isGrouped,
321
400
  executeAsync: async (buildTarget, pkgReporter) => {
401
+ const prebuilt = prebuiltByName?.get(buildTarget.name);
322
402
  const builtPlace = await context.buildPlaceAsync({
323
403
  target: buildTarget.target,
324
404
  outputFileName: publish ? 'publish.rbxl' : 'deploy.rbxl',
325
405
  packagePath: buildTarget.path,
326
406
  packageName: buildTarget.name,
407
+ overrides: prebuilt
408
+ ? {
409
+ placeFile: prebuilt.rbxlPath,
410
+ projectFile: prebuilt.projectPath,
411
+ }
412
+ : undefined,
327
413
  });
328
414
 
329
415
  const injected = await injectDeployMetadataAsync(
@@ -336,7 +422,9 @@ async function _runAsync(args: BatchDeployArgs): Promise<void> {
336
422
  timestamp: deployTimestamp,
337
423
  universeId: buildTarget.target.universeId,
338
424
  placeId: buildTarget.target.placeId,
339
- packageVersion: await readPackageVersionAsync(buildTarget.path),
425
+ packageVersion: prebuilt
426
+ ? prebuilt.packageVersion
427
+ : await readPackageVersionAsync(buildTarget.path),
340
428
  basePlaceId: buildTarget.target.basePlace?.placeId,
341
429
  basePlaceVersion: builtPlace.basePlaceVersion,
342
430
  },
@@ -360,6 +448,18 @@ async function _runAsync(args: BatchDeployArgs): Promise<void> {
360
448
  // Eagerly release build artifacts after upload
361
449
  await context.releaseBuiltPlaceAsync(builtPlace);
362
450
 
451
+ const deployed = {
452
+ packageName: buildTarget.name,
453
+ placeId: buildTarget.target.placeId,
454
+ universeId: buildTarget.target.universeId,
455
+ target: targetName,
456
+ version,
457
+ published: publish,
458
+ basePlaceVersion: builtPlace.basePlaceVersion,
459
+ commit: deployGitInfo.version,
460
+ branch: deployGitInfo.branch,
461
+ };
462
+
363
463
  let logs: string;
364
464
  if (args.smokeTest && buildTarget.target.basePlace) {
365
465
  OutputHelper.verbose('Running post-deploy smoke test...');
@@ -374,8 +474,7 @@ async function _runAsync(args: BatchDeployArgs): Promise<void> {
374
474
  logs = smokeResult.logs;
375
475
  if (!smokeResult.success) {
376
476
  return {
377
- packageName: buildTarget.name,
378
- placeId: buildTarget.target.placeId,
477
+ ...deployed,
379
478
  success: false,
380
479
  logs: _annotateSmokeTestFailure(logs),
381
480
  failureLabel: 'SMOKE TEST FAILED',
@@ -387,8 +486,7 @@ async function _runAsync(args: BatchDeployArgs): Promise<void> {
387
486
  }
388
487
 
389
488
  return {
390
- packageName: buildTarget.name,
391
- placeId: buildTarget.target.placeId,
489
+ ...deployed,
392
490
  success: true,
393
491
  logs,
394
492
  progressSummary: {
@@ -425,7 +523,53 @@ async function _runAsync(args: BatchDeployArgs): Promise<void> {
425
523
  }
426
524
 
427
525
  await reporter.stopAsync();
428
- process.exit(exitCode);
526
+ return exitCode;
527
+ }
528
+
529
+ /**
530
+ * Match each bundled place to this checkout's target of the same name.
531
+ *
532
+ * The bundle is untrusted, so it only gets to say which places it built. Place
533
+ * ids, universe ids and the base place all come from this checkout, which is
534
+ * what a same-repo PR's run would have used. A place this checkout does not
535
+ * know — a fork adding a new game — has nowhere trusted to go, so it fails
536
+ * rather than deploying to ids the fork chose.
537
+ */
538
+ async function _resolveBundleTargetsAsync(
539
+ bundle: PrebuiltDeployBundle,
540
+ targetName: string
541
+ ): Promise<{
542
+ batchTargets: BatchTarget[];
543
+ prebuiltByName: Map<string, PrebuiltDeployPlace>;
544
+ }> {
545
+ if (bundle.target !== targetName) {
546
+ throw new Error(
547
+ `The bundle was built for the "${bundle.target}" target, not "${targetName}". ` +
548
+ `Pass --target ${bundle.target}.`
549
+ );
550
+ }
551
+
552
+ const known = new Map(
553
+ flattenToBatchTargets(await discoverAllTargetPackagesAsync(targetName)).map(
554
+ (t) => [t.name, t]
555
+ )
556
+ );
557
+ const unknown = bundle.places
558
+ .map((p) => p.name)
559
+ .filter((name) => !known.has(name));
560
+ if (unknown.length > 0) {
561
+ throw new Error(
562
+ `The bundle holds places this checkout has no "${targetName}" target for: ` +
563
+ `${unknown.join(
564
+ ', '
565
+ )}. A new place deploys once its deploy.nevermore.json is on this branch.`
566
+ );
567
+ }
568
+
569
+ return {
570
+ batchTargets: bundle.places.map((p) => known.get(p.name)!),
571
+ prebuiltByName: new Map(bundle.places.map((p) => [p.name, p])),
572
+ };
429
573
  }
430
574
 
431
575
  /**
@@ -138,7 +138,7 @@ export const batchTestCommand: CommandModule<
138
138
  })
139
139
  .option('bundle', {
140
140
  describe:
141
- 'Run the places in a *.nevermore.tests.tar written by "batch bundle" instead of finding ' +
141
+ 'Run the places in a *.nevermore.tests.tar written by "batch bundle --test" instead of finding ' +
142
142
  'and building packages. The bundle never chooses where it is uploaded: ' +
143
143
  'that is --batch-place-id/--batch-universe-id, or else the test place this ' +
144
144
  'checkout gives one of its packages.',
@@ -152,10 +152,14 @@ export const batchTestCommand: CommandModule<
152
152
  hidden: true,
153
153
  type: 'string',
154
154
  })
155
+ .option('script-file', {
156
+ hidden: true,
157
+ type: 'string',
158
+ })
155
159
  .check((args) => {
156
- if (args.scriptText !== undefined) {
160
+ if (args.scriptText !== undefined || args.scriptFile !== undefined) {
157
161
  throw new Error(
158
- '--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' +
159
163
  'Run it against a single package instead:\n' +
160
164
  ' cd <package> && nevermore test --cloud --script-text \'print("hi")\''
161
165
  );
@@ -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') {
@@ -59,8 +59,8 @@ export const ciPostTestResultsCommand: CommandModule<
59
59
  .option('run-outcome', {
60
60
  describe:
61
61
  'Outcome of the test step (e.g. "success", "failure"). ' +
62
- 'When the results file is missing and run-outcome is "success", ' +
63
- '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
64
  type: 'string',
65
65
  })
66
66
  .option('sourcemap', {
@@ -78,6 +78,13 @@ export const ciPostTestResultsCommand: CommandModule<
78
78
  } catch {
79
79
  OutputHelper.warn(`Results file not found: ${args.input}`);
80
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
+
81
88
  const reporters = _createGithubReporters(undefined);
82
89
 
83
90
  if (args.runOutcome === 'success') {
@@ -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
  /**