@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
package/README.md CHANGED
@@ -332,6 +332,38 @@ CodePush.sync({
332
332
  ```
333
333
 
334
334
 
335
+ #### 4-2. Asset Diff Archives
336
+
337
+ A release published with a binary patch can carry **asset diff archives** as well - one per
338
+ recently released version. A diff archive holds the patch of the JS bundle, only the assets
339
+ that version does not already have, and a manifest of the files it has to drop. The client
340
+ copies the update it already has installed, applies those, and ends up holding exactly the
341
+ contents of the full archive.
342
+
343
+ So one release has a full archive, a patch archive and up to `--diff-base-count` diff
344
+ archives, and a client downloads one of them per attempt: the diff archive built against
345
+ the update it is running, when the release has one, and the patch archive when it is
346
+ running the bundle in the binary or an update this release was not diffed against.
347
+
348
+ Diff archives are published only when all three of these hold:
349
+ - the release is a binary patch release (`release --binary-bundle-path`),
350
+ - `code-push.config.ts` implements `bundleDownloader`, so the CLI can fetch the earlier releases to diff against,
351
+ - `--diff-base-count` is greater than `0` (it defaults to `3`).
352
+
353
+ ```ts
354
+ bundleDownloader: async (downloadUrl) => {
355
+ const downloadedFilePath = path.join(os.tmpdir(), path.basename(new URL(downloadUrl).pathname));
356
+ // fetch the archive from your storage (S3, GCS, ...) to downloadedFilePath
357
+ return { downloadedFilePath };
358
+ },
359
+ ```
360
+
361
+ Rebuilding the update from a diff archive falls back the way a patch does: the update is
362
+ downloaded in full instead, and `onBinaryPatchResult` reports it as
363
+ `{ status: "fallback", fallbackReason: "package_verification_failed" }`. See
364
+ [Asset diff archives](cli/README.md#asset-diff-archives) for what the release publishes.
365
+
366
+
335
367
  ### 5. Configure the CLI Tool
336
368
 
337
369
  > [!TIP]
@@ -339,7 +371,8 @@ CodePush.sync({
339
371
 
340
372
  **(1) Create a `code-push.config.ts` file in the root directory of your project.**
341
373
 
342
- Then, implement three functions to upload the bundle file and create/update the release history.
374
+ Then, implement three functions to upload the bundle file and create/update the release history,
375
+ plus an optional fourth one - `bundleDownloader` - if you publish [asset diff archives](#4-2-asset-diff-archives).
343
376
  The CLI tool uses these functions to release CodePush updates and manage releases.
344
377
  (These functions are not used at runtime by the library.)
345
378
 
@@ -377,6 +410,15 @@ const Config: CliConfigInterface = {
377
410
  ): Promise<void> => {
378
411
  // ...
379
412
  },
413
+
414
+ // Only needed to publish asset diff archives (see "4-2. Asset Diff Archives").
415
+ // bundleDownloader: async (
416
+ // downloadUrl: string,
417
+ // platform: "ios" | "android",
418
+ // identifier,
419
+ // ): Promise<{downloadedFilePath: string}> => {
420
+ // // ...
421
+ // },
380
422
  };
381
423
 
382
424
  module.exports = Config;
@@ -181,25 +181,39 @@ public class CodePushBinaryPatch {
181
181
  * An archive wraps its files in a single directory, and the manifest's paths are
182
182
  * relative to that directory rather than to the archive. An archive whose files are at
183
183
  * the top level is its own contents root, which is how the tooling that unpacks one
184
- * reads it too.
184
+ * reads it too. A diff archive carries the manifest of the assets to delete beside that
185
+ * wrapper directory, at the root, so that file does not count against the shape.
185
186
  */
186
187
  private static File resolveContentsFolder(File unzippedFolder) {
187
188
  File[] entries = unzippedFolder.listFiles();
188
- if (entries != null && entries.length == 1 && entries[0].isDirectory()) {
189
- return entries[0];
189
+ if (entries == null) {
190
+ return unzippedFolder;
190
191
  }
191
192
 
192
- return unzippedFolder;
193
+ File wrapper = null;
194
+ for (File entry : entries) {
195
+ if (entry.isFile() && entry.getName().equals(CodePushConstants.DIFF_MANIFEST_FILE_NAME)) {
196
+ continue;
197
+ }
198
+ if (wrapper == null && entry.isDirectory()) {
199
+ wrapper = entry;
200
+ continue;
201
+ }
202
+
203
+ return unzippedFolder;
204
+ }
205
+
206
+ return wrapper != null ? wrapper : unzippedFolder;
193
207
  }
194
208
 
195
209
  /**
196
- * Resolves a path the manifest points at, refusing anything that would reach outside
197
- * the archive - an archive is untrusted input, and its manifest is no more trusted
198
- * than its entries.
210
+ * Resolves a path a manifest points at, refusing anything that would reach outside the
211
+ * folder it is relative to - an archive is untrusted input, and its manifest is no more
212
+ * trusted than its entries.
199
213
  *
200
214
  * @return the resolved file, or null when the path is unusable
201
215
  */
202
- private static File resolveInsideFolder(File folder, String relativePath) {
216
+ static File resolveInsideFolder(File folder, String relativePath) {
203
217
  if (isNullOrEmpty(relativePath) || new File(relativePath).isAbsolute()) {
204
218
  return null;
205
219
  }
@@ -110,8 +110,11 @@ public class CodePushUpdateUtils {
110
110
  JSONArray deletedFiles = diffManifest.getJSONArray("deletedFiles");
111
111
  for (int i = 0; i < deletedFiles.length(); i++) {
112
112
  String fileNameToDelete = deletedFiles.getString(i);
113
- File fileToDelete = new File(newPackageFolderPath, fileNameToDelete);
114
- if (fileToDelete.exists()) {
113
+ // The manifest is untrusted input, so an entry that reaches outside the
114
+ // package folder is ignored rather than followed. Whether skipping it left
115
+ // the update whole is the folder hash check's answer to give.
116
+ File fileToDelete = CodePushBinaryPatch.resolveInsideFolder(new File(newPackageFolderPath), fileNameToDelete);
117
+ if (fileToDelete != null && fileToDelete.exists()) {
115
118
  fileToDelete.delete();
116
119
  }
117
120
  }
@@ -87,6 +87,43 @@ public class CodePushBinaryPatchTest {
87
87
  assertNoTemporaryFilesLeft();
88
88
  }
89
89
 
90
+ @Test
91
+ public void restoresTheBundleWhenTheDiffManifestSitsBesideTheContentsDirectory() throws IOException {
92
+ // A diff archive carries the manifest of the assets to delete at its root, beside
93
+ // the one directory its contents are wrapped in.
94
+ File unzippedFolder = mTemporaryFolder.newFolder("diff-archive");
95
+ File contentsFolder = new File(unzippedFolder, "CodePush");
96
+ assertTrue(contentsFolder.mkdirs());
97
+ File diffManifestFile = new File(unzippedFolder, CodePushConstants.DIFF_MANIFEST_FILE_NAME);
98
+ writeFile(diffManifestFile, bytes("{\"deletedFiles\":[]}"));
99
+ assertTrue(mManifestFile.renameTo(new File(contentsFolder, CodePushConstants.BINARY_PATCH_MANIFEST_FILE_NAME)));
100
+ assertTrue(mPatchFile.renameTo(new File(contentsFolder, PATCH_FILE_NAME)));
101
+ File assetFile = new File(contentsFolder, "logo.png");
102
+ writeFile(assetFile, bytes("an asset the diff brought along"));
103
+
104
+ BinaryPatchResult result = new CodePushBinaryPatch(providerOf(BASE_BUNDLE), succeedingApplier())
105
+ .restoreBundle(unzippedFolder.getAbsolutePath(), mWorkingFolder.getAbsolutePath(), BUNDLE_FILE_NAME);
106
+
107
+ assertTrue(result.succeeded());
108
+ assertArrayEquals(TARGET_BUNDLE, readFile(new File(contentsFolder, BUNDLE_FILE_NAME)));
109
+ assertFalse(new File(contentsFolder, PATCH_FILE_NAME).exists());
110
+ assertFalse(new File(contentsFolder, CodePushConstants.BINARY_PATCH_MANIFEST_FILE_NAME).exists());
111
+ assertTrue("the diff manifest is what tells the install which assets to delete", diffManifestFile.exists());
112
+ assertTrue("the rest of the contents is left as the archive delivered it", assetFile.exists());
113
+ assertNoTemporaryFilesLeft();
114
+ }
115
+
116
+ @Test
117
+ public void keepsARootHoldingLooseFilesBesidesTheDiffManifestAsItsOwnContentsRoot() throws IOException {
118
+ writeFile(new File(mContentsFolder, CodePushConstants.DIFF_MANIFEST_FILE_NAME), bytes("{\"deletedFiles\":[]}"));
119
+
120
+ BinaryPatchResult result = restoreBundle(BASE_BUNDLE, succeedingApplier());
121
+
122
+ assertTrue(result.succeeded());
123
+ assertArrayEquals(TARGET_BUNDLE, readFile(mBundleFile));
124
+ assertNoTemporaryFilesLeft();
125
+ }
126
+
90
127
  @Test
91
128
  public void removesWhatAnInterruptedAttemptLeftInTheWorkingDirectory() throws IOException {
92
129
  assertTrue(mWorkingFolder.mkdirs());
@@ -34,6 +34,8 @@ import java.util.HashMap;
34
34
  import java.util.LinkedHashMap;
35
35
  import java.util.List;
36
36
  import java.util.Map;
37
+ import java.util.TreeMap;
38
+ import java.util.TreeSet;
37
39
  import java.util.zip.ZipEntry;
38
40
  import java.util.zip.ZipOutputStream;
39
41
 
@@ -50,11 +52,18 @@ public class CodePushUpdateManagerDownloadTest {
50
52
  /** Every archive wraps its files in one directory, which the manifest paths are relative to. */
51
53
  private static final String CONTENTS_DIR_NAME = "CodePush";
52
54
  private static final String ASSET_PATH = "assets/logo.png";
55
+ /** An asset the update adds, so the diff archive is the only place it comes from. */
56
+ private static final String ADDED_ASSET_PATH = "assets/badge.png";
57
+ /** An asset the installed package holds and the update does without. */
58
+ private static final String DROPPED_ASSET_PATH = "assets/legacy.png";
53
59
 
54
60
  private static final byte[] BASE_BUNDLE = bytes("the bundle inside the app binary");
55
61
  private static final byte[] TARGET_BUNDLE = bytes("the bundle the update wants to run");
62
+ private static final byte[] INSTALLED_BUNDLE = bytes("the bundle of the update already installed");
56
63
  private static final byte[] PATCH = bytes("the difference between the two");
57
64
  private static final byte[] ASSET = bytes("an image the update ships with");
65
+ private static final byte[] ADDED_ASSET = bytes("an image only the newer update ships");
66
+ private static final byte[] DROPPED_ASSET = bytes("an image the newer update leaves behind");
58
67
  private static final byte[] ERROR_PAGE = bytes("<html><body>404 Not Found</body></html>");
59
68
 
60
69
  @Rule
@@ -161,6 +170,83 @@ public class CodePushUpdateManagerDownloadTest {
161
170
  assertFallbackResult(patchResult, BinaryPatchResult.REASON_PACKAGE_VERIFICATION_FAILED);
162
171
  }
163
172
 
173
+ @Test
174
+ public void installsAnAssetDiffUpdateByMergingWithTheInstalledPackage() throws IOException {
175
+ installBaseUpdate();
176
+ Map<String, byte[]> updateContents = assetDiffTargetContents();
177
+ String updateHash = packageHashOf(updateContents);
178
+ String diffUrl = serve("/diff.zip", zipOf(assetDiffArchiveContents(DROPPED_ASSET_PATH)));
179
+ String fullUrl = serve("/full.zip", zipOf(updateContents));
180
+
181
+ JSONObject patchResult = updateManager(applierWriting(TARGET_BUNDLE))
182
+ .downloadPackage(updatePackage(updateHash, fullUrl, diffUrl), BUNDLE_FILE_NAME, ignoreProgress());
183
+
184
+ assertEquals("the update is downloaded as its diff alone, the base being already installed",
185
+ Arrays.asList("/installed.zip", "/diff.zip"), mServer.requestedPaths());
186
+ assertEquals("applied", patchResult.optString("status", null));
187
+ assertFalse("an applied diff has nothing to report a reason for", patchResult.has("fallbackReason"));
188
+ // The asset the diff leaves out is carried over from the installed package, and the
189
+ // one its manifest names is not.
190
+ assertInstalledContents(updateHash, updateContents);
191
+ }
192
+
193
+ @Test
194
+ public void fallsBackToTheFullArchiveWhenTheAssetDiffMergeYieldsTheWrongContents() throws IOException {
195
+ // A manifest that deletes an asset the update keeps: the merge ends up missing a file
196
+ // the release was published with, and the update is not the one the hash is for.
197
+ installBaseUpdate();
198
+ Map<String, byte[]> updateContents = assetDiffTargetContents();
199
+ String updateHash = packageHashOf(updateContents);
200
+ String diffUrl = serve("/diff.zip", zipOf(assetDiffArchiveContents(ASSET_PATH)));
201
+ String fullUrl = serve("/full.zip", zipOf(updateContents));
202
+
203
+ JSONObject patchResult = updateManager(applierWriting(TARGET_BUNDLE))
204
+ .downloadPackage(updatePackage(updateHash, fullUrl, diffUrl), BUNDLE_FILE_NAME, ignoreProgress());
205
+
206
+ assertEquals(Arrays.asList("/installed.zip", "/diff.zip", "/full.zip"), mServer.requestedPaths());
207
+ assertFallbackResult(patchResult, BinaryPatchResult.REASON_PACKAGE_VERIFICATION_FAILED);
208
+ assertInstalledContents(updateHash, updateContents);
209
+ }
210
+
211
+ @Test
212
+ public void fallsBackToTheFullArchiveWhenAnAssetDiffArrivesWithNoInstalledPackage() throws IOException {
213
+ // Nothing is installed, so the assets the diff counts on being there already have
214
+ // nowhere to be copied from and the merge cannot make the update whole.
215
+ Map<String, byte[]> updateContents = assetDiffTargetContents();
216
+ String updateHash = packageHashOf(updateContents);
217
+ String diffUrl = serve("/diff.zip", zipOf(assetDiffArchiveContents(DROPPED_ASSET_PATH)));
218
+ String fullUrl = serve("/full.zip", zipOf(updateContents));
219
+
220
+ JSONObject patchResult = updateManager(applierWriting(TARGET_BUNDLE))
221
+ .downloadPackage(updatePackage(updateHash, fullUrl, diffUrl), BUNDLE_FILE_NAME, ignoreProgress());
222
+
223
+ assertEquals(Arrays.asList("/diff.zip", "/full.zip"), mServer.requestedPaths());
224
+ assertFallbackResult(patchResult, BinaryPatchResult.REASON_PACKAGE_VERIFICATION_FAILED);
225
+ assertInstalledContents(updateHash, updateContents);
226
+ }
227
+
228
+ @Test
229
+ public void leavesAFileOutsideThePackageFolderAloneWhenTheAssetDiffManifestNamesIt() throws IOException {
230
+ // A manifest entry that climbs out of the package folder: the client is not the one
231
+ // that wrote it, so it must not act on it.
232
+ installBaseUpdate();
233
+ File fileOutsideThePackage = mTemporaryFolder.newFile("keep-me.txt");
234
+ Map<String, byte[]> updateContents = assetDiffTargetContents();
235
+ String updateHash = packageHashOf(updateContents);
236
+ String diffUrl = serve("/diff.zip",
237
+ zipOf(assetDiffArchiveContents(DROPPED_ASSET_PATH, CONTENTS_DIR_NAME + "/../../../keep-me.txt")));
238
+ String fullUrl = serve("/full.zip", zipOf(updateContents));
239
+
240
+ JSONObject patchResult = updateManager(applierWriting(TARGET_BUNDLE))
241
+ .downloadPackage(updatePackage(updateHash, fullUrl, diffUrl), BUNDLE_FILE_NAME, ignoreProgress());
242
+
243
+ assertTrue("a manifest entry reaching outside the package folder deleted " + fileOutsideThePackage,
244
+ fileOutsideThePackage.exists());
245
+ // Skipping it costs the update nothing, so the diff still installs.
246
+ assertEquals("applied", patchResult.optString("status", null));
247
+ assertInstalledContents(updateHash, updateContents);
248
+ }
249
+
164
250
  @Test
165
251
  public void announcesEachDownloadOfAFallbackAsItsOwnProgressStream() throws IOException {
166
252
  byte[] fullArchive = zipOf(fullArchiveContents());
@@ -229,6 +315,58 @@ public class CodePushUpdateManagerDownloadTest {
229
315
  assertFalse(binaryPatchFolder.exists());
230
316
  }
231
317
 
318
+ /**
319
+ * The update installed under a hash is these contents and nothing else, which is the
320
+ * whole question for an update that was rebuilt from parts of two archives.
321
+ */
322
+ private void assertInstalledContents(String packageHash, Map<String, byte[]> expectedContents) throws IOException {
323
+ File packageFolder = new File(new File(mDocumentsDirectory, CodePushConstants.CODE_PUSH_FOLDER_PREFIX), packageHash);
324
+ Map<String, byte[]> installedFiles = filesUnder(packageFolder, "");
325
+ // Written last, so its presence also says the folder hash check passed.
326
+ assertTrue("the update is installed without its metadata",
327
+ installedFiles.remove(CodePushConstants.PACKAGE_FILE_NAME) != null);
328
+
329
+ assertEquals("the installed files", new TreeSet<>(expectedContents.keySet()), installedFiles.keySet());
330
+ for (Map.Entry<String, byte[]> expectedFile : expectedContents.entrySet()) {
331
+ assertArrayEquals("the contents of " + expectedFile.getKey(),
332
+ expectedFile.getValue(), installedFiles.get(expectedFile.getKey()));
333
+ }
334
+ }
335
+
336
+ /** Every file in a folder, keyed by its path relative to that folder. */
337
+ private static Map<String, byte[]> filesUnder(File folder, String pathPrefix) throws IOException {
338
+ Map<String, byte[]> files = new TreeMap<>();
339
+ File[] entries = folder.listFiles();
340
+ if (entries == null) {
341
+ return files;
342
+ }
343
+
344
+ for (File entry : entries) {
345
+ String relativePath = pathPrefix.isEmpty() ? entry.getName() : pathPrefix + "/" + entry.getName();
346
+ if (entry.isDirectory()) {
347
+ files.putAll(filesUnder(entry, relativePath));
348
+ } else {
349
+ files.put(relativePath, readFile(entry));
350
+ }
351
+ }
352
+
353
+ return files;
354
+ }
355
+
356
+ /**
357
+ * Downloads an update in full and makes it the current package, which is what the asset
358
+ * diff of a later release is merged into.
359
+ */
360
+ private void installBaseUpdate() throws IOException {
361
+ Map<String, byte[]> contents = installedArchiveContents();
362
+ JSONObject installedPackage = fullUpdatePackage(
363
+ packageHashOf(contents), serve("/installed.zip", zipOf(contents)));
364
+
365
+ CodePushUpdateManager updateManager = updateManager(applierWriting(TARGET_BUNDLE));
366
+ updateManager.downloadPackage(installedPackage, BUNDLE_FILE_NAME, ignoreProgress());
367
+ updateManager.installPackage(installedPackage, false);
368
+ }
369
+
232
370
  private CodePushUpdateManager updateManager(CodePushBinaryPatch.PatchApplier applier) {
233
371
  CodePushBinaryPatch binaryPatch = new CodePushBinaryPatch(new CodePushBinaryPatch.BaseBundleProvider() {
234
372
  @Override
@@ -283,11 +421,60 @@ public class CodePushUpdateManagerDownloadTest {
283
421
  return contents;
284
422
  }
285
423
 
424
+ /** The full archive of the release that is installed when the asset diff arrives. */
425
+ private Map<String, byte[]> installedArchiveContents() {
426
+ Map<String, byte[]> contents = new LinkedHashMap<>();
427
+ contents.put(CONTENTS_DIR_NAME + "/" + BUNDLE_FILE_NAME, INSTALLED_BUNDLE);
428
+ contents.put(CONTENTS_DIR_NAME + "/" + ASSET_PATH, ASSET);
429
+ contents.put(CONTENTS_DIR_NAME + "/" + DROPPED_ASSET_PATH, DROPPED_ASSET);
430
+ return contents;
431
+ }
432
+
433
+ /**
434
+ * An asset diff archive: the patch archive carrying only the assets the update changes,
435
+ * plus the manifest of the files to delete at the archive root, beside the contents
436
+ * directory the manifest's paths are relative to. An asset the installed package already
437
+ * holds unchanged is not shipped at all - the client copies it over.
438
+ */
439
+ private Map<String, byte[]> assetDiffArchiveContents(String deletedAssetPath) {
440
+ Map<String, byte[]> contents = patchArchiveContents();
441
+ contents.remove(CONTENTS_DIR_NAME + "/" + ASSET_PATH);
442
+ contents.put(CONTENTS_DIR_NAME + "/" + ADDED_ASSET_PATH, ADDED_ASSET);
443
+ contents.put(CodePushConstants.DIFF_MANIFEST_FILE_NAME,
444
+ bytes("{\"deletedFiles\":[\"" + CONTENTS_DIR_NAME + "/" + deletedAssetPath + "\"]}"));
445
+ return contents;
446
+ }
447
+
448
+ /** The same archive, with one more path in its manifest of the files to delete. */
449
+ private Map<String, byte[]> assetDiffArchiveContents(String deletedAssetPath, String otherDeletedPath) {
450
+ Map<String, byte[]> contents = assetDiffArchiveContents(deletedAssetPath);
451
+ contents.put(CodePushConstants.DIFF_MANIFEST_FILE_NAME,
452
+ bytes("{\"deletedFiles\":[\"" + CONTENTS_DIR_NAME + "/" + deletedAssetPath + "\",\"" + otherDeletedPath + "\"]}"));
453
+ return contents;
454
+ }
455
+
456
+ /** What an asset diff has to add up to: the contents of the update's full archive. */
457
+ private Map<String, byte[]> assetDiffTargetContents() {
458
+ Map<String, byte[]> contents = fullArchiveContents();
459
+ contents.put(CONTENTS_DIR_NAME + "/" + ADDED_ASSET_PATH, ADDED_ASSET);
460
+ return contents;
461
+ }
462
+
286
463
  private JSONObject updatePackage(String downloadUrl, String binaryPatchDownloadUrl) {
464
+ return updatePackage(mPackageHash, downloadUrl, binaryPatchDownloadUrl);
465
+ }
466
+
467
+ private JSONObject updatePackage(String packageHash, String downloadUrl, String binaryPatchDownloadUrl) {
468
+ JSONObject updatePackage = fullUpdatePackage(packageHash, downloadUrl);
469
+ CodePushUtils.setJSONValueForKey(updatePackage, CodePushConstants.BINARY_PATCH_DOWNLOAD_URL_KEY, binaryPatchDownloadUrl);
470
+ return updatePackage;
471
+ }
472
+
473
+ /** A release with no second archive to try, so it is downloaded in full. */
474
+ private JSONObject fullUpdatePackage(String packageHash, String downloadUrl) {
287
475
  JSONObject updatePackage = new JSONObject();
288
- CodePushUtils.setJSONValueForKey(updatePackage, CodePushConstants.PACKAGE_HASH_KEY, mPackageHash);
476
+ CodePushUtils.setJSONValueForKey(updatePackage, CodePushConstants.PACKAGE_HASH_KEY, packageHash);
289
477
  CodePushUtils.setJSONValueForKey(updatePackage, CodePushConstants.DOWNLOAD_URL_KEY, downloadUrl);
290
- CodePushUtils.setJSONValueForKey(updatePackage, CodePushConstants.BINARY_PATCH_DOWNLOAD_URL_KEY, binaryPatchDownloadUrl);
291
478
  return updatePackage;
292
479
  }
293
480
 
@@ -15,6 +15,7 @@ export async function addToReleaseHistory(
15
15
  mandatory: boolean,
16
16
  enable: boolean,
17
17
  rollout: number | undefined,
18
+ diffPackages: Record<string, string> | undefined,
18
19
  ): Promise<void> {
19
20
  const releaseHistory = await getReleaseHistory(binaryVersion, platform, identifier);
20
21
 
@@ -39,6 +40,12 @@ export async function addToReleaseHistory(
39
40
  newReleaseHistory[appVersion].binaryPatchDownloadUrl = binaryPatchDownloadUrl;
40
41
  }
41
42
 
43
+ // Same for the asset diffs: an entry only names the bases it actually published a
44
+ // diff archive for.
45
+ if (diffPackages && Object.keys(diffPackages).length > 0) {
46
+ newReleaseHistory[appVersion].diffPackages = diffPackages;
47
+ }
48
+
42
49
  if (typeof rollout === 'number') {
43
50
  newReleaseHistory[appVersion].rollout = rollout;
44
51
  }
@@ -12,6 +12,7 @@ jest.mock("./release.js");
12
12
  jest.mock("../../utils/fsUtils.js", () => ({
13
13
  findAndReadConfigFile: () => ({
14
14
  bundleUploader: async () => ({ downloadUrl: 'https://cdn.example.com/bundle' }),
15
+ bundleDownloader: async () => ({ downloadedFilePath: '/tmp/base.zip' }),
15
16
  getReleaseHistory: async () => ({}),
16
17
  setReleaseHistory: async () => undefined,
17
18
  }),
@@ -29,6 +30,8 @@ const ARG_INDEX = {
29
30
  bundleDirectory: 16,
30
31
  baseBundlePath: 19,
31
32
  onOversizedPatch: 20,
33
+ bundleDownloader: 21,
34
+ diffBaseCount: 22,
32
35
  } as const;
33
36
 
34
37
  /**
@@ -115,4 +118,33 @@ describe("release command options", () => {
115
118
  const { release } = await import("./release.js");
116
119
  expect(jest.mocked(release)).not.toHaveBeenCalled();
117
120
  });
121
+
122
+ it("hands the release the bundle downloader from the config and three diff bases by default", async () => {
123
+ const args = await runReleaseCommand(['-b', '1.0.0', '-v', '1.0.1']);
124
+
125
+ expect(typeof args[ARG_INDEX.bundleDownloader]).toBe('function');
126
+ expect(args[ARG_INDEX.diffBaseCount]).toBe(3);
127
+ });
128
+
129
+ it("passes the chosen asset diff base count through to the release", async () => {
130
+ const args = await runReleaseCommand(['-b', '1.0.0', '-v', '1.0.1', '--diff-base-count', '5']);
131
+
132
+ expect(args[ARG_INDEX.diffBaseCount]).toBe(5);
133
+ });
134
+
135
+ it.each([
136
+ ['is negative', '-1'],
137
+ ['is not a number at all', 'many'],
138
+ ])("rejects an asset diff base count that %s", async (_caseName, value) => {
139
+ jest.spyOn(console, 'error').mockImplementation(() => {});
140
+ jest.spyOn(process, 'exit').mockImplementation(((code?: number) => {
141
+ throw new Error(`process.exit(${code})`);
142
+ }) as never);
143
+
144
+ await expect(parseReleaseCommand(['-b', '1.0.0', '-v', '1.0.1', '--diff-base-count', value]))
145
+ .rejects.toThrow('process.exit(1)');
146
+
147
+ const { release } = await import("./release.js");
148
+ expect(jest.mocked(release)).not.toHaveBeenCalled();
149
+ });
118
150
  });
@@ -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 {
5
5
  DEFAULT_OVERSIZED_PATCH_POLICY,
6
6
  OVERSIZED_PATCH_POLICIES,
@@ -30,6 +30,7 @@ type Options = {
30
30
  hashCalc?: boolean;
31
31
  binaryBundlePath?: string;
32
32
  onOversizedPatch: OversizedPatchPolicy;
33
+ diffBaseCount: number;
33
34
  }
34
35
 
35
36
  program.command('release')
@@ -55,6 +56,7 @@ program.command('release')
55
56
  .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')
56
57
  .choices(OVERSIZED_PATCH_POLICIES)
57
58
  .default(DEFAULT_OVERSIZED_PATCH_POLICY))
59
+ .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)
58
60
  .action(async (options: Options) => {
59
61
  const config = findAndReadConfigFile(process.cwd(), options.config);
60
62
 
@@ -63,6 +65,11 @@ program.command('release')
63
65
  process.exit(1);
64
66
  }
65
67
 
68
+ if (!Number.isInteger(options.diffBaseCount) || options.diffBaseCount < 0) {
69
+ console.error('--diff-base-count must be a whole number of releases, 0 or greater.');
70
+ process.exit(1);
71
+ }
72
+
66
73
  if (options.hashCalc && !options.skipBundle) {
67
74
  console.error('--hash-calc option can be used only when --skip-bundle is set to true.');
68
75
  process.exit(1);
@@ -93,11 +100,19 @@ program.command('release')
93
100
  options.hashCalc,
94
101
  baseBundlePath,
95
102
  options.onOversizedPatch,
103
+ config.bundleDownloader,
104
+ options.diffBaseCount,
96
105
  )
97
106
 
98
107
  console.log('🚀 Release completed.')
99
108
  });
100
109
 
110
+ // Not `parseInt` itself: commander hands a coercion function the current value as its
111
+ // second argument, which `parseInt` reads as the radix.
112
+ function parseDecimalInt(value: string): number {
113
+ return parseInt(value, 10);
114
+ }
115
+
101
116
  function parseBoolean(value: string): boolean | undefined {
102
117
  if (value === 'true') return true;
103
118
  if (value === 'false') return false;