@bravemobile/react-native-code-push 13.1.0-beta.1 → 13.1.0-beta.3

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 (36) hide show
  1. package/README.md +43 -1
  2. package/android/app/src/main/java/com/microsoft/codepush/react/CodePushBinaryPatch.java +22 -8
  3. package/android/app/src/main/java/com/microsoft/codepush/react/CodePushUpdateUtils.java +5 -2
  4. package/android/app/src/test/java/com/microsoft/codepush/react/CodePushBinaryPatchTest.java +37 -0
  5. package/android/app/src/test/java/com/microsoft/codepush/react/CodePushUpdateManagerDownloadTest.java +189 -2
  6. package/cli/commands/releaseCommand/addToReleaseHistory.ts +7 -0
  7. package/cli/commands/releaseCommand/index.test.ts +32 -0
  8. package/cli/commands/releaseCommand/index.ts +16 -1
  9. package/cli/commands/releaseCommand/release.test.ts +212 -5
  10. package/cli/commands/releaseCommand/release.ts +128 -6
  11. package/cli/dist/commands/releaseCommand/addToReleaseHistory.js +6 -1
  12. package/cli/dist/commands/releaseCommand/index.js +12 -2
  13. package/cli/dist/commands/releaseCommand/index.test.js +25 -0
  14. package/cli/dist/commands/releaseCommand/release.js +90 -7
  15. package/cli/dist/commands/releaseCommand/release.test.js +175 -5
  16. package/cli/dist/functions/makeAssetDiffBundle.js +114 -0
  17. package/cli/dist/functions/makeAssetDiffBundle.test.js +215 -0
  18. package/cli/dist/functions/resolveAssetDiffBases.js +71 -0
  19. package/cli/dist/functions/resolveAssetDiffBases.test.js +174 -0
  20. package/cli/dist/functions/runExpoBundleCommand.js +4 -0
  21. package/cli/dist/functions/runReactNativeBundleCommand.js +10 -0
  22. package/cli/dist/utils/zip.js +12 -2
  23. package/cli/functions/makeAssetDiffBundle.test.ts +283 -0
  24. package/cli/functions/makeAssetDiffBundle.ts +166 -0
  25. package/cli/functions/resolveAssetDiffBases.test.ts +220 -0
  26. package/cli/functions/resolveAssetDiffBases.ts +101 -0
  27. package/cli/functions/runExpoBundleCommand.ts +4 -0
  28. package/cli/functions/runReactNativeBundleCommand.ts +10 -0
  29. package/cli/package.json +2 -0
  30. package/cli/utils/zip.ts +14 -3
  31. package/ios/CodePush/CodePushBinaryPatch.m +21 -8
  32. package/ios/CodePush/CodePushPackage.m +29 -2
  33. package/package.json +1 -1
  34. package/src/CodePush.js +7 -1
  35. package/src/CodePush.test.js +75 -5
  36. package/typings/react-native-code-push.d.ts +19 -0
@@ -1,9 +1,11 @@
1
+ import crypto from "crypto";
1
2
  import fs from "fs";
2
3
  import os from "os";
3
4
  import path from "path";
4
5
  import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, jest } from "@jest/globals";
5
6
  import { release } from "./release.js";
6
7
  import { makeCodePushBundle } from "../../functions/makeCodePushBundle.js";
8
+ import { ASSET_DIFF_ARCHIVE_INFIX, assetDiffArchiveName } from "../../functions/makeAssetDiffBundle.js";
7
9
  import {
8
10
  BINARY_PATCH_ARCHIVE_SUFFIX,
9
11
  BINARY_PATCH_BASE_RECORD_NAME,
@@ -15,7 +17,7 @@ import {
15
17
  import { applyPatch } from "../../utils/binaryPatch.js";
16
18
  import { generatePackageHashFromDirectory } from "../../utils/hash-utils.js";
17
19
  import { unzip } from "../../utils/unzip.js";
18
- import type { ReleaseHistoryInterface } from "../../../typings/react-native-code-push.d.ts";
20
+ import type { CliConfigInterface, ReleaseHistoryInterface } from "../../../typings/react-native-code-push.d.ts";
19
21
 
20
22
  /**
21
23
  * Covers the release flow around the binary patch option with `--skip-bundle`, which is
@@ -103,16 +105,17 @@ function recordingUploader(uploads: Uploads, failOn?: (filePath: string) => bool
103
105
  };
104
106
  }
105
107
 
106
- function historyStore() {
108
+ function historyStore(existingHistory: ReleaseHistoryInterface = {}, onSave?: () => void) {
107
109
  const saved: ReleaseHistoryInterface[] = [];
108
110
  return {
109
111
  saved,
110
- getReleaseHistory: async () => ({}) as ReleaseHistoryInterface,
112
+ getReleaseHistory: async () => existingHistory,
111
113
  setReleaseHistory: async (
112
114
  _binaryVersion: string,
113
115
  _jsonFilePath: string,
114
116
  releaseInfo: ReleaseHistoryInterface,
115
117
  ) => {
118
+ onSave?.();
116
119
  saved.push(releaseInfo);
117
120
  },
118
121
  };
@@ -127,11 +130,18 @@ type ReleaseOverrides = {
127
130
  onOversizedPatch?: OversizedPatchPolicy;
128
131
  /** Passed in when the case has to read the uploads of a release that threw. */
129
132
  uploads?: Uploads;
133
+ /** What the consumer already has released, which the asset diff bases are picked from. */
134
+ releaseHistory?: ReleaseHistoryInterface;
135
+ bundleDownloader?: CliConfigInterface['bundleDownloader'];
136
+ diffBaseCount?: number;
130
137
  };
131
138
 
132
139
  async function runRelease(staged: StagedBundle, overrides: ReleaseOverrides = {}) {
133
140
  const uploads: Uploads = overrides.uploads ?? [];
134
- const history = historyStore();
141
+ const uploadCountsWhenHistorySaved: number[] = [];
142
+ const history = historyStore(overrides.releaseHistory, () => {
143
+ uploadCountsWhenHistorySaved.push(uploads.length);
144
+ });
135
145
 
136
146
  await release(
137
147
  recordingUploader(uploads, overrides.uploadFailsFor),
@@ -156,9 +166,11 @@ async function runRelease(staged: StagedBundle, overrides: ReleaseOverrides = {}
156
166
  undefined,
157
167
  overrides.binaryBundlePath,
158
168
  overrides.onOversizedPatch,
169
+ overrides.bundleDownloader,
170
+ overrides.diffBaseCount,
159
171
  );
160
172
 
161
- return { uploads, releaseHistories: history.saved };
173
+ return { uploads, releaseHistories: history.saved, uploadCountsWhenHistorySaved };
162
174
  }
163
175
 
164
176
  let logs: string[];
@@ -580,3 +592,198 @@ describe("release --on-oversized-patch", () => {
580
592
  expect(uploads).toHaveLength(2);
581
593
  });
582
594
  });
595
+
596
+ /**
597
+ * A release can also ship a diff per recently released package: the patch archive without
598
+ * the files that package already holds. The bases come out of the release history and are
599
+ * downloaded through the consumer's `bundleDownloader`, so a release only builds diffs
600
+ * where the consumer supplied one.
601
+ */
602
+ describe("release with asset diff bases", () => {
603
+ /**
604
+ * The asset both packages hold. Sized like a real asset because a diff is only shipped
605
+ * when it comes out smaller than the patch archive it was derived from.
606
+ */
607
+ const sharedAsset = crypto.randomBytes(4096);
608
+
609
+ const PREVIOUS_APP_VERSION = '9.9.9';
610
+
611
+ function updateFiles(): Record<string, Buffer | string> {
612
+ return { 'main.jsbundle': fs.readFileSync(targetFixture), 'assets/keep.png': sharedAsset };
613
+ }
614
+
615
+ type StagedBaseRelease = { downloadUrl: string, packageHash: string, archivePath: string };
616
+
617
+ /**
618
+ * A package released earlier, as the history describes it and as the CDN serves it.
619
+ *
620
+ * @param ownAsset An asset only this release holds, which is what makes two staged base releases two packages rather than one.
621
+ */
622
+ async function stageBaseRelease(caseName: string, ownAsset?: string): Promise<StagedBaseRelease> {
623
+ const caseDir = fs.mkdtempSync(path.join(workDir, `${caseName}-base-`));
624
+ const contentsPath = path.join(caseDir, CONTENTS_DIR_NAME);
625
+ const files: Record<string, Buffer | string> = {
626
+ 'main.jsbundle': fs.readFileSync(baseFixture),
627
+ 'assets/keep.png': sharedAsset,
628
+ ...(ownAsset === undefined ? {} : { 'assets/dropped.png': ownAsset }),
629
+ };
630
+
631
+ for (const [relativePath, content] of Object.entries(files)) {
632
+ const filePath = path.join(contentsPath, relativePath);
633
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
634
+ fs.writeFileSync(filePath, content);
635
+ }
636
+
637
+ const archiveDir = path.join(caseDir, 'archive');
638
+ const { bundleFileName } = await makeCodePushBundle(contentsPath, archiveDir);
639
+
640
+ return {
641
+ downloadUrl: `https://cdn.example.com/released/${bundleFileName}`,
642
+ packageHash: bundleFileName,
643
+ archivePath: path.join(archiveDir, bundleFileName),
644
+ };
645
+ }
646
+
647
+ function releaseHistoryOf(base: StagedBaseRelease, versions = [PREVIOUS_APP_VERSION]): ReleaseHistoryInterface {
648
+ return Object.fromEntries(versions.map((version) => [
649
+ version,
650
+ {
651
+ enabled: true,
652
+ mandatory: false,
653
+ downloadUrl: base.downloadUrl,
654
+ packageHash: base.packageHash,
655
+ },
656
+ ]));
657
+ }
658
+
659
+ /** Serves the staged base archives, and records which releases were asked for. */
660
+ function recordingDownloader(bases: StagedBaseRelease[], downloads: string[]): CliConfigInterface['bundleDownloader'] {
661
+ return async (downloadUrl: string) => {
662
+ downloads.push(downloadUrl);
663
+ const base = bases.find((candidate) => candidate.downloadUrl === downloadUrl);
664
+ if (!base) {
665
+ throw new Error(`the downloader was called with an unexpected url: ${downloadUrl}`);
666
+ }
667
+ return { downloadedFilePath: base.archivePath };
668
+ };
669
+ }
670
+
671
+ it("publishes asset diff archives against recent releases and records them in the history", async () => {
672
+ const staged = await stageBundleOutput("asset-diff", updateFiles());
673
+ const base = await stageBaseRelease("asset-diff");
674
+ const downloads: string[] = [];
675
+
676
+ const { uploads, releaseHistories, uploadCountsWhenHistorySaved } = await runRelease(staged, {
677
+ binaryBundlePath: baseFixture,
678
+ releaseHistory: releaseHistoryOf(base),
679
+ bundleDownloader: recordingDownloader([base], downloads),
680
+ });
681
+
682
+ const diffFileName = `${staged.bundleFileName}${ASSET_DIFF_ARCHIVE_INFIX}${base.packageHash}.zip`;
683
+ // The diff is the last artifact: it is an optimisation on top of the patch, which
684
+ // is itself an optimisation on top of the full archive.
685
+ expect(uploads.map(({ filePath }) => path.basename(filePath))).toEqual([
686
+ staged.bundleFileName,
687
+ `${staged.bundleFileName}${BINARY_PATCH_ARCHIVE_SUFFIX}`,
688
+ diffFileName,
689
+ ]);
690
+ expect(downloads).toEqual([base.downloadUrl]);
691
+ // Every artifact is uploaded before the history points at any of them.
692
+ expect(uploadCountsWhenHistorySaved).toEqual([3]);
693
+ expect(releaseHistories[0][APP_VERSION].diffPackages).toEqual({
694
+ [base.packageHash]: uploads[2].downloadUrl,
695
+ });
696
+ expect(fs.existsSync(path.join(staged.bundleDirectory, diffFileName))).toBe(true);
697
+ });
698
+
699
+ it("publishes without diff archives when the config has no bundle downloader", async () => {
700
+ const staged = await stageBundleOutput("no-downloader", updateFiles());
701
+ const base = await stageBaseRelease("no-downloader");
702
+
703
+ const { uploads, releaseHistories } = await runRelease(staged, {
704
+ binaryBundlePath: baseFixture,
705
+ releaseHistory: releaseHistoryOf(base),
706
+ });
707
+
708
+ expect(uploads).toHaveLength(2);
709
+ // A release without diffs says nothing about them, so a client reading this history
710
+ // behaves exactly as it did before asset diffs existed.
711
+ expect(releaseHistories[0][APP_VERSION]).not.toHaveProperty('diffPackages');
712
+ });
713
+
714
+ it("publishes without diff archives when --diff-base-count is zero", async () => {
715
+ const staged = await stageBundleOutput("zero-bases", updateFiles());
716
+ const base = await stageBaseRelease("zero-bases");
717
+ const downloads: string[] = [];
718
+
719
+ const { uploads, releaseHistories } = await runRelease(staged, {
720
+ binaryBundlePath: baseFixture,
721
+ releaseHistory: releaseHistoryOf(base),
722
+ bundleDownloader: recordingDownloader([base], downloads),
723
+ diffBaseCount: 0,
724
+ });
725
+
726
+ expect(uploads).toHaveLength(2);
727
+ expect(downloads).toEqual([]);
728
+ expect(releaseHistories[0][APP_VERSION]).not.toHaveProperty('diffPackages');
729
+ });
730
+
731
+ it("builds one diff archive when two releases hold the same package", async () => {
732
+ const staged = await stageBundleOutput("repeated-base", updateFiles());
733
+ const base = await stageBaseRelease("repeated-base");
734
+ const downloads: string[] = [];
735
+
736
+ const { uploads, releaseHistories } = await runRelease(staged, {
737
+ binaryBundlePath: baseFixture,
738
+ releaseHistory: releaseHistoryOf(base, ['9.9.8', PREVIOUS_APP_VERSION]),
739
+ bundleDownloader: recordingDownloader([base], downloads),
740
+ });
741
+
742
+ expect(downloads).toHaveLength(2);
743
+ expect(uploads.filter(({ filePath }) => filePath.includes(ASSET_DIFF_ARCHIVE_INFIX))).toHaveLength(1);
744
+ expect(releaseHistories[0][APP_VERSION].diffPackages).toEqual({
745
+ [base.packageHash]: uploads[2].downloadUrl,
746
+ });
747
+ });
748
+
749
+ it("releases the diffs that uploaded when one of them is rejected", async () => {
750
+ const staged = await stageBundleOutput("diff-upload-failure", updateFiles());
751
+ // Two packages to diff against, one of whose diffs the storage backend refuses.
752
+ const rejected = await stageBaseRelease("diff-upload-failure-rejected");
753
+ const kept = await stageBaseRelease("diff-upload-failure-kept", 'an asset of the older release');
754
+ const downloads: string[] = [];
755
+
756
+ const { uploads, releaseHistories } = await runRelease(staged, {
757
+ binaryBundlePath: baseFixture,
758
+ releaseHistory: {
759
+ ...releaseHistoryOf(kept, ['9.9.8']),
760
+ ...releaseHistoryOf(rejected, [PREVIOUS_APP_VERSION]),
761
+ },
762
+ bundleDownloader: recordingDownloader([kept, rejected], downloads),
763
+ uploadFailsFor: (filePath) => filePath.includes(rejected.packageHash),
764
+ });
765
+
766
+ expect(uploads.map(({ filePath }) => path.basename(filePath))).toEqual([
767
+ staged.bundleFileName,
768
+ `${staged.bundleFileName}${BINARY_PATCH_ARCHIVE_SUFFIX}`,
769
+ `${staged.bundleFileName}${ASSET_DIFF_ARCHIVE_INFIX}${kept.packageHash}.zip`,
770
+ ]);
771
+ // The release goes out with the two artifacts it needs, and describes only the diff
772
+ // a client can actually download.
773
+ expect(releaseHistories[0][APP_VERSION].diffPackages).toEqual({
774
+ [kept.packageHash]: uploads[2].downloadUrl,
775
+ });
776
+ expect(logs.filter((line) => line.startsWith('warn:')).join('\n')).toMatch(
777
+ new RegExp(`Skipping the asset diff archive against ${rejected.packageHash}`),
778
+ );
779
+ });
780
+
781
+ it("still picks the full bundle when diff archives sit in the output directory", async () => {
782
+ const staged = await stageBundleOutput("leftover-diff");
783
+ fs.writeFileSync(path.join(staged.bundleDirectory, assetDiffArchiveName('stale', 'stale-base')), 'stale');
784
+
785
+ const { uploads } = await runRelease(staged, { binaryBundlePath: baseFixture });
786
+
787
+ expect(path.basename(uploads[0].filePath)).toBe(staged.bundleFileName);
788
+ });
789
+ });
@@ -2,9 +2,11 @@ import fs from "fs";
2
2
  import path from "path";
3
3
  import { bundleCodePush, resolveJsBundleName } from "../bundleCommand/bundleCodePush.js";
4
4
  import { addToReleaseHistory } from "./addToReleaseHistory.js";
5
- import type { CliConfigInterface } from "../../../typings/react-native-code-push.d.ts";
5
+ import type { CliConfigInterface, ReleaseHistoryInterface } from "../../../typings/react-native-code-push.d.ts";
6
6
  import { generatePackageHashFromDirectory } from "../../utils/hash-utils.js";
7
7
  import { unzip } from "../../utils/unzip.js";
8
+ import { ASSET_DIFF_ARCHIVE_INFIX, makeAssetDiffBundle } from "../../functions/makeAssetDiffBundle.js";
9
+ import { resolveAssetDiffBases } from "../../functions/resolveAssetDiffBases.js";
8
10
  import {
9
11
  BINARY_PATCH_ARCHIVE_SUFFIX,
10
12
  BINARY_PATCH_BASE_RECORD_NAME,
@@ -20,6 +22,9 @@ import {
20
22
  type OversizedPatchPolicy,
21
23
  } from "../../functions/makeBinaryPatchBundle.js";
22
24
 
25
+ /** How many of the most recent releases a release builds asset diff archives against. */
26
+ export const DEFAULT_DIFF_BASE_COUNT = 3;
27
+
23
28
  export async function release(
24
29
  bundleUploader: CliConfigInterface['bundleUploader'],
25
30
  getReleaseHistory: CliConfigInterface['getReleaseHistory'],
@@ -42,6 +47,8 @@ export async function release(
42
47
  hashCalc?: boolean,
43
48
  baseBundlePath?: string,
44
49
  onOversizedPatch: OversizedPatchPolicy = DEFAULT_OVERSIZED_PATCH_POLICY,
50
+ bundleDownloader?: CliConfigInterface['bundleDownloader'],
51
+ diffBaseCount: number = DEFAULT_DIFF_BASE_COUNT,
45
52
  ): Promise<void> {
46
53
  if (baseBundlePath) {
47
54
  // Checked before the bundler runs, so the wrong base bundle costs a second rather
@@ -77,6 +84,22 @@ export async function release(
77
84
  })
78
85
  : null;
79
86
 
87
+ // A diff is the patch archive with what a base package already holds taken out, so it
88
+ // only exists where a patch does - and only a consumer that can hand back a released
89
+ // archive can supply the bases to diff against.
90
+ const assetDiffArtifacts = binaryPatch && bundleDownloader && diffBaseCount > 0
91
+ ? await makeAssetDiffArtifacts({
92
+ patchBundleFilePath: binaryPatch.patchBundleFilePath,
93
+ releaseHistory: await getReleaseHistory(binaryVersion, platform, identifier),
94
+ diffBaseCount,
95
+ bundleDownloader,
96
+ bundleDirectory,
97
+ packageHash,
98
+ platform,
99
+ identifier,
100
+ })
101
+ : [];
102
+
80
103
  // Every artifact is uploaded before the release history is touched, so a failed
81
104
  // upload leaves the history describing only updates that can actually be downloaded.
82
105
  const downloadUrl = await uploadArtifact(bundleUploader, bundleFilePath, platform, identifier, 'bundle');
@@ -85,6 +108,22 @@ export async function release(
85
108
  patchDownloadUrl = await uploadArtifact(bundleUploader, binaryPatch.patchBundleFilePath, platform, identifier, 'binary patch bundle');
86
109
  console.log(`log: Binary patch archive uploaded (download url: ${patchDownloadUrl})`);
87
110
  }
111
+ const diffPackages: Record<string, string> = {};
112
+ for (const { basePackageHash, filePath } of assetDiffArtifacts) {
113
+ // A diff is an optimisation on top of the patch archive, which is uploaded by now:
114
+ // one that cannot be uploaded is left out of the history instead of failing the
115
+ // release the other two artifacts are ready for.
116
+ try {
117
+ const diffDownloadUrl = await uploadFile(bundleUploader, filePath, platform, identifier);
118
+ diffPackages[basePackageHash] = diffDownloadUrl;
119
+ console.log(`log: Asset diff archive against ${basePackageHash} uploaded (download url: ${diffDownloadUrl})`);
120
+ } catch (error) {
121
+ console.warn(
122
+ `warn: Skipping the asset diff archive against ${basePackageHash}: it could not be uploaded ` +
123
+ `(${error instanceof Error ? error.message : String(error)}).`,
124
+ );
125
+ }
126
+ }
88
127
 
89
128
  await addToReleaseHistory(
90
129
  appVersion,
@@ -99,6 +138,7 @@ export async function release(
99
138
  mandatory,
100
139
  enable,
101
140
  rollout,
141
+ Object.keys(diffPackages).length > 0 ? diffPackages : undefined,
102
142
  )
103
143
 
104
144
  if (!skipCleanup) {
@@ -111,9 +151,11 @@ function cleanUpOutputs(dir: string) {
111
151
  }
112
152
 
113
153
  function readBundleFileNameFrom(bundleDirectory: string): string {
114
- // A previous release of the same bundle may have left its patch archive here, and
115
- // that archive is derived from the bundle file rather than a candidate for release.
116
- const files = fs.readdirSync(bundleDirectory).filter((file) => !file.endsWith(BINARY_PATCH_ARCHIVE_SUFFIX));
154
+ // A previous release of the same bundle may have left its patch and diff archives
155
+ // here, and those are derived from the bundle file rather than candidates for release.
156
+ const files = fs.readdirSync(bundleDirectory).filter(
157
+ (file) => !file.endsWith(BINARY_PATCH_ARCHIVE_SUFFIX) && !file.includes(ASSET_DIFF_ARCHIVE_INFIX),
158
+ );
117
159
  if (files.length !== 1) {
118
160
  console.error('The bundlePath must contain only one file.');
119
161
  process.exit(1);
@@ -229,6 +271,76 @@ async function makeBinaryPatchArtifact({
229
271
  }
230
272
  }
231
273
 
274
+ type AssetDiffArtifact = { basePackageHash: string, filePath: string };
275
+
276
+ /**
277
+ * Builds a diff artifact per recently released package, for the clients holding one.
278
+ *
279
+ * A base that cannot be downloaded or verified is left out by the resolver, and a diff
280
+ * that comes out no smaller than the patch archive is not published. Either way the
281
+ * release goes out with its full and patch archives, serving fewer diffs.
282
+ *
283
+ * @return {Promise<AssetDiffArtifact[]>} The diff archives to upload, each naming the package it was built against.
284
+ */
285
+ async function makeAssetDiffArtifacts({
286
+ patchBundleFilePath,
287
+ releaseHistory,
288
+ diffBaseCount,
289
+ bundleDownloader,
290
+ bundleDirectory,
291
+ packageHash,
292
+ platform,
293
+ identifier,
294
+ }: {
295
+ patchBundleFilePath: string;
296
+ releaseHistory: ReleaseHistoryInterface;
297
+ diffBaseCount: number;
298
+ bundleDownloader: NonNullable<CliConfigInterface['bundleDownloader']>;
299
+ bundleDirectory: string;
300
+ packageHash: string;
301
+ platform: 'ios' | 'android';
302
+ identifier: string | undefined;
303
+ }): Promise<AssetDiffArtifact[]> {
304
+ const bases = await resolveAssetDiffBases({
305
+ releaseHistory,
306
+ diffBaseCount,
307
+ bundleDownloader,
308
+ platform,
309
+ identifier,
310
+ });
311
+
312
+ const artifacts: AssetDiffArtifact[] = [];
313
+ const builtAgainst = new Set<string>();
314
+ for (const base of bases) {
315
+ // Releasing identical contents twice leaves two history entries describing one
316
+ // package, and their diff is the same archive built - and named - twice.
317
+ if (builtAgainst.has(base.basePackageHash)) {
318
+ continue;
319
+ }
320
+ builtAgainst.add(base.basePackageHash);
321
+
322
+ try {
323
+ const assetDiff = await makeAssetDiffBundle({
324
+ patchBundleFilePath,
325
+ baseBundleFilePath: base.baseBundleFilePath,
326
+ bundleDirectory,
327
+ packageHash,
328
+ basePackageHash: base.basePackageHash,
329
+ });
330
+ if (assetDiff) {
331
+ artifacts.push({ basePackageHash: base.basePackageHash, filePath: assetDiff.diffBundleFilePath });
332
+ }
333
+ } catch (error) {
334
+ console.warn(
335
+ `warn: Skipping the asset diff against ${base.basePackageHash}: it could not be built ` +
336
+ `(${error instanceof Error ? error.message : String(error)}).`,
337
+ );
338
+ }
339
+ }
340
+
341
+ return artifacts;
342
+ }
343
+
232
344
  /**
233
345
  * Cross-checks the base bundle against the record a build hook exported next to it.
234
346
  *
@@ -303,6 +415,7 @@ function warnOnBaseBundleMismatch(outputPath: string, baseBundlePath: string): v
303
415
  }
304
416
  }
305
417
 
418
+ /** Uploads an artifact the release cannot go out without, so a failure ends the program. */
306
419
  async function uploadArtifact(
307
420
  bundleUploader: CliConfigInterface['bundleUploader'],
308
421
  filePath: string,
@@ -311,10 +424,19 @@ async function uploadArtifact(
311
424
  artifactName: string,
312
425
  ): Promise<string> {
313
426
  try {
314
- const { downloadUrl } = await bundleUploader(filePath, platform, identifier);
315
- return downloadUrl
427
+ return await uploadFile(bundleUploader, filePath, platform, identifier);
316
428
  } catch (error) {
317
429
  console.error(`Failed to upload the ${artifactName} file. Exiting the program.\n`, error)
318
430
  process.exit(1)
319
431
  }
320
432
  }
433
+
434
+ async function uploadFile(
435
+ bundleUploader: CliConfigInterface['bundleUploader'],
436
+ filePath: string,
437
+ platform: 'ios' | 'android',
438
+ identifier: string | undefined,
439
+ ): Promise<string> {
440
+ const { downloadUrl } = await bundleUploader(filePath, platform, identifier);
441
+ return downloadUrl
442
+ }
@@ -1,6 +1,6 @@
1
1
  import path from "path";
2
2
  import fs from "fs";
3
- export async function addToReleaseHistory(appVersion, binaryVersion, bundleDownloadUrl, binaryPatchDownloadUrl, packageHash, getReleaseHistory, setReleaseHistory, platform, identifier, mandatory, enable, rollout) {
3
+ export async function addToReleaseHistory(appVersion, binaryVersion, bundleDownloadUrl, binaryPatchDownloadUrl, packageHash, getReleaseHistory, setReleaseHistory, platform, identifier, mandatory, enable, rollout, diffPackages) {
4
4
  const releaseHistory = await getReleaseHistory(binaryVersion, platform, identifier);
5
5
  const updateInfo = releaseHistory[appVersion];
6
6
  if (updateInfo) {
@@ -19,6 +19,11 @@ export async function addToReleaseHistory(appVersion, binaryVersion, bundleDownl
19
19
  if (binaryPatchDownloadUrl) {
20
20
  newReleaseHistory[appVersion].binaryPatchDownloadUrl = binaryPatchDownloadUrl;
21
21
  }
22
+ // Same for the asset diffs: an entry only names the bases it actually published a
23
+ // diff archive for.
24
+ if (diffPackages && Object.keys(diffPackages).length > 0) {
25
+ newReleaseHistory[appVersion].diffPackages = diffPackages;
26
+ }
22
27
  if (typeof rollout === 'number') {
23
28
  newReleaseHistory[appVersion].rollout = rollout;
24
29
  }
@@ -1,6 +1,6 @@
1
1
  import { program, Option } from "commander";
2
2
  import { findAndReadConfigFile } from "../../utils/fsUtils.js";
3
- import { release } from "./release.js";
3
+ import { DEFAULT_DIFF_BASE_COUNT, release } from "./release.js";
4
4
  import { DEFAULT_OVERSIZED_PATCH_POLICY, OVERSIZED_PATCH_POLICIES, resolveBinaryBundlePathOption, } from "../../functions/makeBinaryPatchBundle.js";
5
5
  import { OUTPUT_BUNDLE_DIR, CONFIG_FILE_NAME, ROOT_OUTPUT_DIR, ENTRY_FILE } from "../../constant.js";
6
6
  program.command('release')
@@ -26,21 +26,31 @@ program.command('release')
26
26
  .addOption(new Option('--on-oversized-patch <policy>', 'what to do when the binary patch bundle is not smaller than the full bundle: "skip" releases the full bundle only, "fail" stops the release before any upload')
27
27
  .choices(OVERSIZED_PATCH_POLICIES)
28
28
  .default(DEFAULT_OVERSIZED_PATCH_POLICY))
29
+ .option('--diff-base-count <number>', 'how many recent releases to build asset diff archives against (0 disables). Requires `bundleDownloader` in the config file.', parseDecimalInt, DEFAULT_DIFF_BASE_COUNT)
29
30
  .action(async (options) => {
30
31
  const config = findAndReadConfigFile(process.cwd(), options.config);
31
32
  if (typeof options.rollout === 'number' && (options.rollout < 0 || options.rollout > 100)) {
32
33
  console.error('Rollout percentage number must be between 0 and 100 (inclusive).');
33
34
  process.exit(1);
34
35
  }
36
+ if (!Number.isInteger(options.diffBaseCount) || options.diffBaseCount < 0) {
37
+ console.error('--diff-base-count must be a whole number of releases, 0 or greater.');
38
+ process.exit(1);
39
+ }
35
40
  if (options.hashCalc && !options.skipBundle) {
36
41
  console.error('--hash-calc option can be used only when --skip-bundle is set to true.');
37
42
  process.exit(1);
38
43
  }
39
44
  // Resolved before bundling so a wrong path fails now rather than after a build.
40
45
  const baseBundlePath = resolveBinaryBundlePathOption(options.binaryBundlePath);
41
- await release(config.bundleUploader, config.getReleaseHistory, config.setReleaseHistory, options.binaryVersion, options.appVersion, options.framework, options.platform, options.identifier, options.outputPath, options.entryFile, options.jsBundleName, options.mandatory, options.enable, options.rollout, options.skipBundle, options.skipCleanup, `${options.outputPath}/${options.outputBundleDir}`, options.outputMetroDir, options.hashCalc, baseBundlePath, options.onOversizedPatch);
46
+ await release(config.bundleUploader, config.getReleaseHistory, config.setReleaseHistory, options.binaryVersion, options.appVersion, options.framework, options.platform, options.identifier, options.outputPath, options.entryFile, options.jsBundleName, options.mandatory, options.enable, options.rollout, options.skipBundle, options.skipCleanup, `${options.outputPath}/${options.outputBundleDir}`, options.outputMetroDir, options.hashCalc, baseBundlePath, options.onOversizedPatch, config.bundleDownloader, options.diffBaseCount);
42
47
  console.log('🚀 Release completed.');
43
48
  });
49
+ // Not `parseInt` itself: commander hands a coercion function the current value as its
50
+ // second argument, which `parseInt` reads as the radix.
51
+ function parseDecimalInt(value) {
52
+ return parseInt(value, 10);
53
+ }
44
54
  function parseBoolean(value) {
45
55
  if (value === 'true')
46
56
  return true;
@@ -10,6 +10,7 @@ jest.mock("./release.js");
10
10
  jest.mock("../../utils/fsUtils.js", () => ({
11
11
  findAndReadConfigFile: () => ({
12
12
  bundleUploader: async () => ({ downloadUrl: 'https://cdn.example.com/bundle' }),
13
+ bundleDownloader: async () => ({ downloadedFilePath: '/tmp/base.zip' }),
13
14
  getReleaseHistory: async () => ({}),
14
15
  setReleaseHistory: async () => undefined,
15
16
  }),
@@ -26,6 +27,8 @@ const ARG_INDEX = {
26
27
  bundleDirectory: 16,
27
28
  baseBundlePath: 19,
28
29
  onOversizedPatch: 20,
30
+ bundleDownloader: 21,
31
+ diffBaseCount: 22,
29
32
  };
30
33
  /**
31
34
  * Parses a `release` invocation against the real command definition. Commander is asked
@@ -93,4 +96,26 @@ describe("release command options", () => {
93
96
  const { release } = await import("./release.js");
94
97
  expect(jest.mocked(release)).not.toHaveBeenCalled();
95
98
  });
99
+ it("hands the release the bundle downloader from the config and three diff bases by default", async () => {
100
+ const args = await runReleaseCommand(['-b', '1.0.0', '-v', '1.0.1']);
101
+ expect(typeof args[ARG_INDEX.bundleDownloader]).toBe('function');
102
+ expect(args[ARG_INDEX.diffBaseCount]).toBe(3);
103
+ });
104
+ it("passes the chosen asset diff base count through to the release", async () => {
105
+ const args = await runReleaseCommand(['-b', '1.0.0', '-v', '1.0.1', '--diff-base-count', '5']);
106
+ expect(args[ARG_INDEX.diffBaseCount]).toBe(5);
107
+ });
108
+ it.each([
109
+ ['is negative', '-1'],
110
+ ['is not a number at all', 'many'],
111
+ ])("rejects an asset diff base count that %s", async (_caseName, value) => {
112
+ jest.spyOn(console, 'error').mockImplementation(() => { });
113
+ jest.spyOn(process, 'exit').mockImplementation(((code) => {
114
+ throw new Error(`process.exit(${code})`);
115
+ }));
116
+ await expect(parseReleaseCommand(['-b', '1.0.0', '-v', '1.0.1', '--diff-base-count', value]))
117
+ .rejects.toThrow('process.exit(1)');
118
+ const { release } = await import("./release.js");
119
+ expect(jest.mocked(release)).not.toHaveBeenCalled();
120
+ });
96
121
  });