@bravemobile/react-native-code-push 13.1.0-beta.2 → 13.1.0-beta.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +48 -1
- package/android/app/src/main/java/com/microsoft/codepush/react/CodePushBinaryPatch.java +22 -8
- package/android/app/src/main/java/com/microsoft/codepush/react/CodePushUpdateUtils.java +5 -2
- package/android/app/src/test/java/com/microsoft/codepush/react/CodePushBinaryPatchTest.java +37 -0
- package/android/app/src/test/java/com/microsoft/codepush/react/CodePushUpdateManagerDownloadTest.java +189 -2
- package/cli/commands/releaseCommand/addToReleaseHistory.ts +7 -0
- package/cli/commands/releaseCommand/index.test.ts +32 -0
- package/cli/commands/releaseCommand/index.ts +16 -1
- package/cli/commands/releaseCommand/release.test.ts +259 -10
- package/cli/commands/releaseCommand/release.ts +163 -8
- package/cli/dist/commands/releaseCommand/addToReleaseHistory.js +6 -1
- package/cli/dist/commands/releaseCommand/index.js +12 -2
- package/cli/dist/commands/releaseCommand/index.test.js +25 -0
- package/cli/dist/commands/releaseCommand/release.js +108 -10
- package/cli/dist/commands/releaseCommand/release.test.js +205 -7
- package/cli/dist/functions/makeAssetDiffBundle.js +114 -0
- package/cli/dist/functions/makeAssetDiffBundle.test.js +215 -0
- package/cli/dist/functions/resolveAssetDiffBases.js +76 -0
- package/cli/dist/functions/resolveAssetDiffBases.test.js +188 -0
- package/cli/dist/utils/zip.js +12 -2
- package/cli/functions/makeAssetDiffBundle.test.ts +283 -0
- package/cli/functions/makeAssetDiffBundle.ts +166 -0
- package/cli/functions/resolveAssetDiffBases.test.ts +234 -0
- package/cli/functions/resolveAssetDiffBases.ts +108 -0
- package/cli/package.json +2 -0
- package/cli/utils/zip.ts +14 -3
- package/ios/CodePush/CodePushBinaryPatch.m +21 -8
- package/ios/CodePush/CodePushPackage.m +29 -2
- package/package.json +1 -1
- package/src/CodePush.js +7 -1
- package/src/CodePush.test.js +75 -5
- package/typings/react-native-code-push.d.ts +49 -0
|
@@ -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
|
});
|
|
@@ -4,8 +4,12 @@ import { bundleCodePush, resolveJsBundleName } from "../bundleCommand/bundleCode
|
|
|
4
4
|
import { addToReleaseHistory } from "./addToReleaseHistory.js";
|
|
5
5
|
import { generatePackageHashFromDirectory } from "../../utils/hash-utils.js";
|
|
6
6
|
import { unzip } from "../../utils/unzip.js";
|
|
7
|
+
import { ASSET_DIFF_ARCHIVE_INFIX, makeAssetDiffBundle } from "../../functions/makeAssetDiffBundle.js";
|
|
8
|
+
import { resolveAssetDiffBases } from "../../functions/resolveAssetDiffBases.js";
|
|
7
9
|
import { BINARY_PATCH_ARCHIVE_SUFFIX, BINARY_PATCH_BASE_RECORD_NAME, DEFAULT_OVERSIZED_PATCH_POLICY, extractCodePushBundleContents, formatBinaryPatchSummary, hashBundleFile, isPatchArchiveOversized, makeBinaryPatchBundle, readBinaryPatchBaseRecord, } from "../../functions/makeBinaryPatchBundle.js";
|
|
8
|
-
|
|
10
|
+
/** How many of the most recent releases a release builds asset diff archives against. */
|
|
11
|
+
export const DEFAULT_DIFF_BASE_COUNT = 3;
|
|
12
|
+
export async function release(bundleUploader, getReleaseHistory, setReleaseHistory, binaryVersion, appVersion, framework, platform, identifier, outputPath, entryFile, jsBundleName, mandatory, enable, rollout, skipBundle, skipCleanup, bundleDirectory, outputMetroDir, hashCalc, baseBundlePath, onOversizedPatch = DEFAULT_OVERSIZED_PATCH_POLICY, bundleDownloader, diffBaseCount = DEFAULT_DIFF_BASE_COUNT) {
|
|
9
13
|
if (baseBundlePath) {
|
|
10
14
|
// Checked before the bundler runs, so the wrong base bundle costs a second rather
|
|
11
15
|
// than a full build.
|
|
@@ -36,15 +40,59 @@ export async function release(bundleUploader, getReleaseHistory, setReleaseHisto
|
|
|
36
40
|
onOversizedPatch,
|
|
37
41
|
})
|
|
38
42
|
: null;
|
|
43
|
+
// A diff is the patch archive with what a base package already holds taken out, so it
|
|
44
|
+
// only exists where a patch does - and only a consumer that can hand back a released
|
|
45
|
+
// archive can supply the bases to diff against.
|
|
46
|
+
const assetDiffArtifacts = binaryPatch && bundleDownloader && diffBaseCount > 0
|
|
47
|
+
? await makeAssetDiffArtifacts({
|
|
48
|
+
patchBundleFilePath: binaryPatch.patchBundleFilePath,
|
|
49
|
+
releaseHistory: await getReleaseHistory(binaryVersion, platform, identifier),
|
|
50
|
+
diffBaseCount,
|
|
51
|
+
bundleDownloader,
|
|
52
|
+
bundleDirectory,
|
|
53
|
+
packageHash,
|
|
54
|
+
targetBinaryVersion: binaryVersion,
|
|
55
|
+
platform,
|
|
56
|
+
identifier,
|
|
57
|
+
})
|
|
58
|
+
: [];
|
|
39
59
|
// Every artifact is uploaded before the release history is touched, so a failed
|
|
40
60
|
// upload leaves the history describing only updates that can actually be downloaded.
|
|
41
|
-
const downloadUrl = await uploadArtifact(bundleUploader, bundleFilePath, platform, identifier,
|
|
61
|
+
const downloadUrl = await uploadArtifact(bundleUploader, bundleFilePath, platform, identifier, {
|
|
62
|
+
type: 'full-bundle',
|
|
63
|
+
targetBinaryVersion: binaryVersion,
|
|
64
|
+
packageHash,
|
|
65
|
+
}, 'bundle');
|
|
42
66
|
let patchDownloadUrl;
|
|
43
67
|
if (binaryPatch) {
|
|
44
|
-
patchDownloadUrl = await uploadArtifact(bundleUploader, binaryPatch.patchBundleFilePath, platform, identifier,
|
|
68
|
+
patchDownloadUrl = await uploadArtifact(bundleUploader, binaryPatch.patchBundleFilePath, platform, identifier, {
|
|
69
|
+
type: 'binary-patch',
|
|
70
|
+
targetBinaryVersion: binaryVersion,
|
|
71
|
+
packageHash,
|
|
72
|
+
}, 'binary patch bundle');
|
|
45
73
|
console.log(`log: Binary patch archive uploaded (download url: ${patchDownloadUrl})`);
|
|
46
74
|
}
|
|
47
|
-
|
|
75
|
+
const diffPackages = {};
|
|
76
|
+
for (const { basePackageHash, filePath } of assetDiffArtifacts) {
|
|
77
|
+
// A diff is an optimisation on top of the patch archive, which is uploaded by now:
|
|
78
|
+
// one that cannot be uploaded is left out of the history instead of failing the
|
|
79
|
+
// release the other two artifacts are ready for.
|
|
80
|
+
try {
|
|
81
|
+
const diffDownloadUrl = await uploadFile(bundleUploader, filePath, platform, identifier, {
|
|
82
|
+
type: 'asset-diff',
|
|
83
|
+
targetBinaryVersion: binaryVersion,
|
|
84
|
+
packageHash,
|
|
85
|
+
basePackageHash,
|
|
86
|
+
});
|
|
87
|
+
diffPackages[basePackageHash] = diffDownloadUrl;
|
|
88
|
+
console.log(`log: Asset diff archive against ${basePackageHash} uploaded (download url: ${diffDownloadUrl})`);
|
|
89
|
+
}
|
|
90
|
+
catch (error) {
|
|
91
|
+
console.warn(`warn: Skipping the asset diff archive against ${basePackageHash}: it could not be uploaded ` +
|
|
92
|
+
`(${error instanceof Error ? error.message : String(error)}).`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
await addToReleaseHistory(appVersion, binaryVersion, downloadUrl, patchDownloadUrl, packageHash, getReleaseHistory, setReleaseHistory, platform, identifier, mandatory, enable, rollout, Object.keys(diffPackages).length > 0 ? diffPackages : undefined);
|
|
48
96
|
if (!skipCleanup) {
|
|
49
97
|
cleanUpOutputs(outputPath);
|
|
50
98
|
}
|
|
@@ -53,9 +101,9 @@ function cleanUpOutputs(dir) {
|
|
|
53
101
|
fs.rmSync(dir, { recursive: true });
|
|
54
102
|
}
|
|
55
103
|
function readBundleFileNameFrom(bundleDirectory) {
|
|
56
|
-
// A previous release of the same bundle may have left its patch
|
|
57
|
-
//
|
|
58
|
-
const files = fs.readdirSync(bundleDirectory).filter((file) => !file.endsWith(BINARY_PATCH_ARCHIVE_SUFFIX));
|
|
104
|
+
// A previous release of the same bundle may have left its patch and diff archives
|
|
105
|
+
// here, and those are derived from the bundle file rather than candidates for release.
|
|
106
|
+
const files = fs.readdirSync(bundleDirectory).filter((file) => !file.endsWith(BINARY_PATCH_ARCHIVE_SUFFIX) && !file.includes(ASSET_DIFF_ARCHIVE_INFIX));
|
|
59
107
|
if (files.length !== 1) {
|
|
60
108
|
console.error('The bundlePath must contain only one file.');
|
|
61
109
|
process.exit(1);
|
|
@@ -137,6 +185,52 @@ async function makeBinaryPatchArtifact({ baseBundlePath, contentsPath, bundleFil
|
|
|
137
185
|
}
|
|
138
186
|
}
|
|
139
187
|
}
|
|
188
|
+
/**
|
|
189
|
+
* Builds a diff artifact per recently released package, for the clients holding one.
|
|
190
|
+
*
|
|
191
|
+
* A base that cannot be downloaded or verified is left out by the resolver, and a diff
|
|
192
|
+
* that comes out no smaller than the patch archive is not published. Either way the
|
|
193
|
+
* release goes out with its full and patch archives, serving fewer diffs.
|
|
194
|
+
*
|
|
195
|
+
* @return {Promise<AssetDiffArtifact[]>} The diff archives to upload, each naming the package it was built against.
|
|
196
|
+
*/
|
|
197
|
+
async function makeAssetDiffArtifacts({ patchBundleFilePath, releaseHistory, diffBaseCount, bundleDownloader, bundleDirectory, packageHash, targetBinaryVersion, platform, identifier, }) {
|
|
198
|
+
const bases = await resolveAssetDiffBases({
|
|
199
|
+
releaseHistory,
|
|
200
|
+
diffBaseCount,
|
|
201
|
+
bundleDownloader,
|
|
202
|
+
platform,
|
|
203
|
+
identifier,
|
|
204
|
+
targetBinaryVersion,
|
|
205
|
+
});
|
|
206
|
+
const artifacts = [];
|
|
207
|
+
const builtAgainst = new Set();
|
|
208
|
+
for (const base of bases) {
|
|
209
|
+
// Releasing identical contents twice leaves two history entries describing one
|
|
210
|
+
// package, and their diff is the same archive built - and named - twice.
|
|
211
|
+
if (builtAgainst.has(base.basePackageHash)) {
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
builtAgainst.add(base.basePackageHash);
|
|
215
|
+
try {
|
|
216
|
+
const assetDiff = await makeAssetDiffBundle({
|
|
217
|
+
patchBundleFilePath,
|
|
218
|
+
baseBundleFilePath: base.baseBundleFilePath,
|
|
219
|
+
bundleDirectory,
|
|
220
|
+
packageHash,
|
|
221
|
+
basePackageHash: base.basePackageHash,
|
|
222
|
+
});
|
|
223
|
+
if (assetDiff) {
|
|
224
|
+
artifacts.push({ basePackageHash: base.basePackageHash, filePath: assetDiff.diffBundleFilePath });
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
catch (error) {
|
|
228
|
+
console.warn(`warn: Skipping the asset diff against ${base.basePackageHash}: it could not be built ` +
|
|
229
|
+
`(${error instanceof Error ? error.message : String(error)}).`);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return artifacts;
|
|
233
|
+
}
|
|
140
234
|
/**
|
|
141
235
|
* Cross-checks the base bundle against the record a build hook exported next to it.
|
|
142
236
|
*
|
|
@@ -198,13 +292,17 @@ function warnOnBaseBundleMismatch(outputPath, baseBundlePath) {
|
|
|
198
292
|
'The patch is valid but larger than an aligned one.');
|
|
199
293
|
}
|
|
200
294
|
}
|
|
201
|
-
|
|
295
|
+
/** Uploads an artifact the release cannot go out without, so a failure ends the program. */
|
|
296
|
+
async function uploadArtifact(bundleUploader, filePath, platform, identifier, artifact, artifactName) {
|
|
202
297
|
try {
|
|
203
|
-
|
|
204
|
-
return downloadUrl;
|
|
298
|
+
return await uploadFile(bundleUploader, filePath, platform, identifier, artifact);
|
|
205
299
|
}
|
|
206
300
|
catch (error) {
|
|
207
301
|
console.error(`Failed to upload the ${artifactName} file. Exiting the program.\n`, error);
|
|
208
302
|
process.exit(1);
|
|
209
303
|
}
|
|
210
304
|
}
|
|
305
|
+
async function uploadFile(bundleUploader, filePath, platform, identifier, artifact) {
|
|
306
|
+
const { downloadUrl } = await bundleUploader(filePath, platform, identifier, artifact);
|
|
307
|
+
return downloadUrl;
|
|
308
|
+
}
|
|
@@ -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 { BINARY_PATCH_ARCHIVE_SUFFIX, BINARY_PATCH_BASE_RECORD_NAME, BINARY_PATCH_MANIFEST_NAME, hashBundleFile, writeBinaryPatchBaseRecord, } from "../../functions/makeBinaryPatchBundle.js";
|
|
8
10
|
import { applyPatch } from "../../utils/binaryPatch.js";
|
|
9
11
|
import { generatePackageHashFromDirectory } from "../../utils/hash-utils.js";
|
|
@@ -64,33 +66,37 @@ class ProcessExitError extends Error {
|
|
|
64
66
|
}
|
|
65
67
|
}
|
|
66
68
|
function recordingUploader(uploads, failOn) {
|
|
67
|
-
return async (filePath) => {
|
|
69
|
+
return async (filePath, _platform, _identifier, artifact) => {
|
|
68
70
|
if (failOn?.(filePath)) {
|
|
69
71
|
throw new Error(`upload rejected: ${path.basename(filePath)}`);
|
|
70
72
|
}
|
|
71
73
|
const downloadUrl = `https://cdn.example.com/${path.basename(filePath)}`;
|
|
72
|
-
uploads.push({ filePath, downloadUrl, logCountBeforeUpload: logs.length });
|
|
74
|
+
uploads.push({ artifact, filePath, downloadUrl, logCountBeforeUpload: logs.length });
|
|
73
75
|
return { downloadUrl };
|
|
74
76
|
};
|
|
75
77
|
}
|
|
76
|
-
function historyStore() {
|
|
78
|
+
function historyStore(existingHistory = {}, onSave) {
|
|
77
79
|
const saved = [];
|
|
78
80
|
return {
|
|
79
81
|
saved,
|
|
80
|
-
getReleaseHistory: async () =>
|
|
82
|
+
getReleaseHistory: async () => existingHistory,
|
|
81
83
|
setReleaseHistory: async (_binaryVersion, _jsonFilePath, releaseInfo) => {
|
|
84
|
+
onSave?.();
|
|
82
85
|
saved.push(releaseInfo);
|
|
83
86
|
},
|
|
84
87
|
};
|
|
85
88
|
}
|
|
86
89
|
async function runRelease(staged, overrides = {}) {
|
|
87
90
|
const uploads = overrides.uploads ?? [];
|
|
88
|
-
const
|
|
91
|
+
const uploadCountsWhenHistorySaved = [];
|
|
92
|
+
const history = historyStore(overrides.releaseHistory, () => {
|
|
93
|
+
uploadCountsWhenHistorySaved.push(uploads.length);
|
|
94
|
+
});
|
|
89
95
|
await release(recordingUploader(uploads, overrides.uploadFailsFor), history.getReleaseHistory, history.setReleaseHistory, BINARY_VERSION, APP_VERSION, undefined, overrides.platform ?? 'ios', undefined, staged.outputPath, 'index.ts',
|
|
90
96
|
// Left unset unless the case is about -j, the way commander leaves it.
|
|
91
97
|
overrides.jsBundleName, false, true, undefined, true, // skipBundle
|
|
92
|
-
overrides.skipCleanup ?? true, staged.bundleDirectory, undefined, undefined, overrides.binaryBundlePath, overrides.onOversizedPatch);
|
|
93
|
-
return { uploads, releaseHistories: history.saved };
|
|
98
|
+
overrides.skipCleanup ?? true, staged.bundleDirectory, undefined, undefined, overrides.binaryBundlePath, overrides.onOversizedPatch, overrides.bundleDownloader, overrides.diffBaseCount);
|
|
99
|
+
return { uploads, releaseHistories: history.saved, uploadCountsWhenHistorySaved };
|
|
94
100
|
}
|
|
95
101
|
let logs;
|
|
96
102
|
beforeAll(() => {
|
|
@@ -118,11 +124,21 @@ afterEach(() => {
|
|
|
118
124
|
fs.rmSync(path.resolve(process.cwd(), `${BINARY_VERSION}.json`), { force: true });
|
|
119
125
|
});
|
|
120
126
|
describe("release without --binary-bundle-path", () => {
|
|
127
|
+
it("keeps existing three-argument uploaders callable", async () => {
|
|
128
|
+
const legacyUploader = async (source, _platform, _identifier) => ({ downloadUrl: source });
|
|
129
|
+
const uploader = legacyUploader;
|
|
130
|
+
await expect(uploader('bundle.zip', 'ios')).resolves.toEqual({ downloadUrl: 'bundle.zip' });
|
|
131
|
+
});
|
|
121
132
|
it("uploads the full bundle only, exactly as before binary patches existed", async () => {
|
|
122
133
|
const staged = await stageBundleOutput("full-only");
|
|
123
134
|
const { uploads, releaseHistories } = await runRelease(staged);
|
|
124
135
|
expect(uploads).toHaveLength(1);
|
|
125
136
|
expect(uploads[0].filePath).toBe(`${staged.bundleDirectory}/${staged.bundleFileName}`);
|
|
137
|
+
expect(uploads[0].artifact).toEqual({
|
|
138
|
+
type: 'full-bundle',
|
|
139
|
+
targetBinaryVersion: BINARY_VERSION,
|
|
140
|
+
packageHash: staged.bundleFileName,
|
|
141
|
+
});
|
|
126
142
|
expect(fs.readdirSync(staged.bundleDirectory)).toEqual([staged.bundleFileName]);
|
|
127
143
|
expect(releaseHistories[0][APP_VERSION]).toEqual({
|
|
128
144
|
enabled: true,
|
|
@@ -365,3 +381,185 @@ describe("release --on-oversized-patch", () => {
|
|
|
365
381
|
expect(uploads).toHaveLength(2);
|
|
366
382
|
});
|
|
367
383
|
});
|
|
384
|
+
/**
|
|
385
|
+
* A release can also ship a diff per recently released package: the patch archive without
|
|
386
|
+
* the files that package already holds. The bases come out of the release history and are
|
|
387
|
+
* downloaded through the consumer's `bundleDownloader`, so a release only builds diffs
|
|
388
|
+
* where the consumer supplied one.
|
|
389
|
+
*/
|
|
390
|
+
describe("release with asset diff bases", () => {
|
|
391
|
+
/**
|
|
392
|
+
* The asset both packages hold. Sized like a real asset because a diff is only shipped
|
|
393
|
+
* when it comes out smaller than the patch archive it was derived from.
|
|
394
|
+
*/
|
|
395
|
+
const sharedAsset = crypto.randomBytes(4096);
|
|
396
|
+
const PREVIOUS_APP_VERSION = '9.9.9';
|
|
397
|
+
function updateFiles() {
|
|
398
|
+
return { 'main.jsbundle': fs.readFileSync(targetFixture), 'assets/keep.png': sharedAsset };
|
|
399
|
+
}
|
|
400
|
+
/**
|
|
401
|
+
* A package released earlier, as the history describes it and as the CDN serves it.
|
|
402
|
+
*
|
|
403
|
+
* @param ownAsset An asset only this release holds, which is what makes two staged base releases two packages rather than one.
|
|
404
|
+
*/
|
|
405
|
+
async function stageBaseRelease(caseName, ownAsset) {
|
|
406
|
+
const caseDir = fs.mkdtempSync(path.join(workDir, `${caseName}-base-`));
|
|
407
|
+
const contentsPath = path.join(caseDir, CONTENTS_DIR_NAME);
|
|
408
|
+
const files = {
|
|
409
|
+
'main.jsbundle': fs.readFileSync(baseFixture),
|
|
410
|
+
'assets/keep.png': sharedAsset,
|
|
411
|
+
...(ownAsset === undefined ? {} : { 'assets/dropped.png': ownAsset }),
|
|
412
|
+
};
|
|
413
|
+
for (const [relativePath, content] of Object.entries(files)) {
|
|
414
|
+
const filePath = path.join(contentsPath, relativePath);
|
|
415
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
416
|
+
fs.writeFileSync(filePath, content);
|
|
417
|
+
}
|
|
418
|
+
const archiveDir = path.join(caseDir, 'archive');
|
|
419
|
+
const { bundleFileName } = await makeCodePushBundle(contentsPath, archiveDir);
|
|
420
|
+
return {
|
|
421
|
+
downloadUrl: `https://cdn.example.com/released/${bundleFileName}`,
|
|
422
|
+
packageHash: bundleFileName,
|
|
423
|
+
archivePath: path.join(archiveDir, bundleFileName),
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
function releaseHistoryOf(base, versions = [PREVIOUS_APP_VERSION]) {
|
|
427
|
+
return Object.fromEntries(versions.map((version) => [
|
|
428
|
+
version,
|
|
429
|
+
{
|
|
430
|
+
enabled: true,
|
|
431
|
+
mandatory: false,
|
|
432
|
+
downloadUrl: base.downloadUrl,
|
|
433
|
+
packageHash: base.packageHash,
|
|
434
|
+
},
|
|
435
|
+
]));
|
|
436
|
+
}
|
|
437
|
+
/** Serves the staged base archives, and records which releases were asked for. */
|
|
438
|
+
function recordingDownloader(bases, downloads) {
|
|
439
|
+
return async (archive) => {
|
|
440
|
+
downloads.push(archive.downloadUrl);
|
|
441
|
+
const base = bases.find((candidate) => candidate.downloadUrl === archive.downloadUrl);
|
|
442
|
+
if (!base) {
|
|
443
|
+
throw new Error(`the downloader was called with an unexpected url: ${archive.downloadUrl}`);
|
|
444
|
+
}
|
|
445
|
+
return { downloadedFilePath: base.archivePath };
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
it("publishes asset diff archives against recent releases and records them in the history", async () => {
|
|
449
|
+
const staged = await stageBundleOutput("asset-diff", updateFiles());
|
|
450
|
+
const base = await stageBaseRelease("asset-diff");
|
|
451
|
+
const downloads = [];
|
|
452
|
+
const { uploads, releaseHistories, uploadCountsWhenHistorySaved } = await runRelease(staged, {
|
|
453
|
+
binaryBundlePath: baseFixture,
|
|
454
|
+
releaseHistory: releaseHistoryOf(base),
|
|
455
|
+
bundleDownloader: recordingDownloader([base], downloads),
|
|
456
|
+
});
|
|
457
|
+
const diffFileName = `${staged.bundleFileName}${ASSET_DIFF_ARCHIVE_INFIX}${base.packageHash}.zip`;
|
|
458
|
+
// The diff is the last artifact: it is an optimisation on top of the patch, which
|
|
459
|
+
// is itself an optimisation on top of the full archive.
|
|
460
|
+
expect(uploads.map(({ filePath }) => path.basename(filePath))).toEqual([
|
|
461
|
+
staged.bundleFileName,
|
|
462
|
+
`${staged.bundleFileName}${BINARY_PATCH_ARCHIVE_SUFFIX}`,
|
|
463
|
+
diffFileName,
|
|
464
|
+
]);
|
|
465
|
+
expect(uploads.map(({ artifact }) => artifact)).toEqual([
|
|
466
|
+
{
|
|
467
|
+
type: 'full-bundle',
|
|
468
|
+
targetBinaryVersion: BINARY_VERSION,
|
|
469
|
+
packageHash: staged.bundleFileName,
|
|
470
|
+
},
|
|
471
|
+
{
|
|
472
|
+
type: 'binary-patch',
|
|
473
|
+
targetBinaryVersion: BINARY_VERSION,
|
|
474
|
+
packageHash: staged.bundleFileName,
|
|
475
|
+
},
|
|
476
|
+
{
|
|
477
|
+
type: 'asset-diff',
|
|
478
|
+
targetBinaryVersion: BINARY_VERSION,
|
|
479
|
+
packageHash: staged.bundleFileName,
|
|
480
|
+
basePackageHash: base.packageHash,
|
|
481
|
+
},
|
|
482
|
+
]);
|
|
483
|
+
expect(downloads).toEqual([base.downloadUrl]);
|
|
484
|
+
// Every artifact is uploaded before the history points at any of them.
|
|
485
|
+
expect(uploadCountsWhenHistorySaved).toEqual([3]);
|
|
486
|
+
expect(releaseHistories[0][APP_VERSION].diffPackages).toEqual({
|
|
487
|
+
[base.packageHash]: uploads[2].downloadUrl,
|
|
488
|
+
});
|
|
489
|
+
expect(fs.existsSync(path.join(staged.bundleDirectory, diffFileName))).toBe(true);
|
|
490
|
+
});
|
|
491
|
+
it("publishes without diff archives when the config has no bundle downloader", async () => {
|
|
492
|
+
const staged = await stageBundleOutput("no-downloader", updateFiles());
|
|
493
|
+
const base = await stageBaseRelease("no-downloader");
|
|
494
|
+
const { uploads, releaseHistories } = await runRelease(staged, {
|
|
495
|
+
binaryBundlePath: baseFixture,
|
|
496
|
+
releaseHistory: releaseHistoryOf(base),
|
|
497
|
+
});
|
|
498
|
+
expect(uploads).toHaveLength(2);
|
|
499
|
+
// A release without diffs says nothing about them, so a client reading this history
|
|
500
|
+
// behaves exactly as it did before asset diffs existed.
|
|
501
|
+
expect(releaseHistories[0][APP_VERSION]).not.toHaveProperty('diffPackages');
|
|
502
|
+
});
|
|
503
|
+
it("publishes without diff archives when --diff-base-count is zero", async () => {
|
|
504
|
+
const staged = await stageBundleOutput("zero-bases", updateFiles());
|
|
505
|
+
const base = await stageBaseRelease("zero-bases");
|
|
506
|
+
const downloads = [];
|
|
507
|
+
const { uploads, releaseHistories } = await runRelease(staged, {
|
|
508
|
+
binaryBundlePath: baseFixture,
|
|
509
|
+
releaseHistory: releaseHistoryOf(base),
|
|
510
|
+
bundleDownloader: recordingDownloader([base], downloads),
|
|
511
|
+
diffBaseCount: 0,
|
|
512
|
+
});
|
|
513
|
+
expect(uploads).toHaveLength(2);
|
|
514
|
+
expect(downloads).toEqual([]);
|
|
515
|
+
expect(releaseHistories[0][APP_VERSION]).not.toHaveProperty('diffPackages');
|
|
516
|
+
});
|
|
517
|
+
it("builds one diff archive when two releases hold the same package", async () => {
|
|
518
|
+
const staged = await stageBundleOutput("repeated-base", updateFiles());
|
|
519
|
+
const base = await stageBaseRelease("repeated-base");
|
|
520
|
+
const downloads = [];
|
|
521
|
+
const { uploads, releaseHistories } = await runRelease(staged, {
|
|
522
|
+
binaryBundlePath: baseFixture,
|
|
523
|
+
releaseHistory: releaseHistoryOf(base, ['9.9.8', PREVIOUS_APP_VERSION]),
|
|
524
|
+
bundleDownloader: recordingDownloader([base], downloads),
|
|
525
|
+
});
|
|
526
|
+
expect(downloads).toHaveLength(2);
|
|
527
|
+
expect(uploads.filter(({ filePath }) => filePath.includes(ASSET_DIFF_ARCHIVE_INFIX))).toHaveLength(1);
|
|
528
|
+
expect(releaseHistories[0][APP_VERSION].diffPackages).toEqual({
|
|
529
|
+
[base.packageHash]: uploads[2].downloadUrl,
|
|
530
|
+
});
|
|
531
|
+
});
|
|
532
|
+
it("releases the diffs that uploaded when one of them is rejected", async () => {
|
|
533
|
+
const staged = await stageBundleOutput("diff-upload-failure", updateFiles());
|
|
534
|
+
// Two packages to diff against, one of whose diffs the storage backend refuses.
|
|
535
|
+
const rejected = await stageBaseRelease("diff-upload-failure-rejected");
|
|
536
|
+
const kept = await stageBaseRelease("diff-upload-failure-kept", 'an asset of the older release');
|
|
537
|
+
const downloads = [];
|
|
538
|
+
const { uploads, releaseHistories } = await runRelease(staged, {
|
|
539
|
+
binaryBundlePath: baseFixture,
|
|
540
|
+
releaseHistory: {
|
|
541
|
+
...releaseHistoryOf(kept, ['9.9.8']),
|
|
542
|
+
...releaseHistoryOf(rejected, [PREVIOUS_APP_VERSION]),
|
|
543
|
+
},
|
|
544
|
+
bundleDownloader: recordingDownloader([kept, rejected], downloads),
|
|
545
|
+
uploadFailsFor: (filePath) => filePath.includes(rejected.packageHash),
|
|
546
|
+
});
|
|
547
|
+
expect(uploads.map(({ filePath }) => path.basename(filePath))).toEqual([
|
|
548
|
+
staged.bundleFileName,
|
|
549
|
+
`${staged.bundleFileName}${BINARY_PATCH_ARCHIVE_SUFFIX}`,
|
|
550
|
+
`${staged.bundleFileName}${ASSET_DIFF_ARCHIVE_INFIX}${kept.packageHash}.zip`,
|
|
551
|
+
]);
|
|
552
|
+
// The release goes out with the two artifacts it needs, and describes only the diff
|
|
553
|
+
// a client can actually download.
|
|
554
|
+
expect(releaseHistories[0][APP_VERSION].diffPackages).toEqual({
|
|
555
|
+
[kept.packageHash]: uploads[2].downloadUrl,
|
|
556
|
+
});
|
|
557
|
+
expect(logs.filter((line) => line.startsWith('warn:')).join('\n')).toMatch(new RegExp(`Skipping the asset diff archive against ${rejected.packageHash}`));
|
|
558
|
+
});
|
|
559
|
+
it("still picks the full bundle when diff archives sit in the output directory", async () => {
|
|
560
|
+
const staged = await stageBundleOutput("leftover-diff");
|
|
561
|
+
fs.writeFileSync(path.join(staged.bundleDirectory, assetDiffArchiveName('stale', 'stale-base')), 'stale');
|
|
562
|
+
const { uploads } = await runRelease(staged, { binaryBundlePath: baseFixture });
|
|
563
|
+
expect(path.basename(uploads[0].filePath)).toBe(staged.bundleFileName);
|
|
564
|
+
});
|
|
565
|
+
});
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Builds the diff artifact a client can install on top of one particular base package.
|
|
3
|
+
*
|
|
4
|
+
* A diff archive is the patch archive of this release with every file the base package
|
|
5
|
+
* already holds byte for byte taken out, plus a manifest naming the files the base holds
|
|
6
|
+
* that the update dropped. A client copies its installed base package, applies the
|
|
7
|
+
* deletions, rebuilds the JS bundle from the patch and overlays what the diff shipped -
|
|
8
|
+
* which leaves it holding exactly the contents of the full archive, and therefore the
|
|
9
|
+
* same `packageHash`. One release can serve a diff per base it was built against.
|
|
10
|
+
*
|
|
11
|
+
* The diff is derived from the artifacts the release already produced rather than from
|
|
12
|
+
* the bundler, so it costs no extra bundling and cannot drift from what was published.
|
|
13
|
+
*/
|
|
14
|
+
import fs from "fs";
|
|
15
|
+
import path from "path";
|
|
16
|
+
import shell from "shelljs";
|
|
17
|
+
import { normalizePath } from "../utils/file-utils.js";
|
|
18
|
+
import { walk } from "../utils/promisfied-fs.js";
|
|
19
|
+
import { zipDirectoryContents } from "../utils/zip.js";
|
|
20
|
+
import { BINARY_PATCH_MANIFEST_NAME, extractCodePushBundleContents, } from "./makeBinaryPatchBundle.js";
|
|
21
|
+
/**
|
|
22
|
+
* Manifest listing what a client must delete from its copy of the base package. It sits
|
|
23
|
+
* at the archive root, outside the contents directory, because that is where the native
|
|
24
|
+
* clients look for it - and because a file inside the contents would change the package
|
|
25
|
+
* hash the merged folder has to reproduce.
|
|
26
|
+
*/
|
|
27
|
+
export const ASSET_DIFF_MANIFEST_NAME = 'hotcodepush.json';
|
|
28
|
+
/** Separates the two hashes, so a diff archive names both the update and the base it needs. */
|
|
29
|
+
export const ASSET_DIFF_ARCHIVE_INFIX = '-diff-';
|
|
30
|
+
const TEMP_ASSET_DIFF_DIR_NAME = 'temp_contents_for_asset_diff';
|
|
31
|
+
export function assetDiffArchiveName(packageHash, basePackageHash) {
|
|
32
|
+
return `${packageHash}${ASSET_DIFF_ARCHIVE_INFIX}${basePackageHash}.zip`;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Creates the diff archive for a release against one base package.
|
|
36
|
+
*
|
|
37
|
+
* @param patchBundleFilePath {string} Patch archive this release already built
|
|
38
|
+
* @param baseBundleFilePath {string} Full archive of the base release, downloaded locally
|
|
39
|
+
* @param bundleDirectory {string} Directory the release artifacts are written to
|
|
40
|
+
* @param packageHash {string} Package hash of this release
|
|
41
|
+
* @param basePackageHash {string} Package hash of the base release
|
|
42
|
+
* @return The diff archive, or `null` when it is not smaller than the patch archive it
|
|
43
|
+
* was derived from, in which case there is nothing to gain from publishing it.
|
|
44
|
+
*/
|
|
45
|
+
export async function makeAssetDiffBundle({ patchBundleFilePath, baseBundleFilePath, bundleDirectory, packageHash, basePackageHash, }) {
|
|
46
|
+
// Kept next to the release output rather than in the system temp directory so a crash
|
|
47
|
+
// leaves the leftovers where the rest of the build output is cleaned up. Named after
|
|
48
|
+
// the base so building the diffs of one release against several bases at once cannot
|
|
49
|
+
// have two of them share a working directory.
|
|
50
|
+
const tempRoot = path.join(path.dirname(path.resolve(bundleDirectory)), `${TEMP_ASSET_DIFF_DIR_NAME}_${basePackageHash}`);
|
|
51
|
+
const stagingRoot = path.join(tempRoot, 'archive');
|
|
52
|
+
fs.rmSync(tempRoot, { recursive: true, force: true });
|
|
53
|
+
try {
|
|
54
|
+
// Extracted into separate parents: `extractCodePushBundleContents` reuses one
|
|
55
|
+
// directory name, so a shared parent would have the second unpack wipe the first.
|
|
56
|
+
const { contentsPath: patchContents } = await extractCodePushBundleContents(patchBundleFilePath, path.join(tempRoot, 'patch-archive'));
|
|
57
|
+
const { contentsPath: baseContents } = await extractCodePushBundleContents(baseBundleFilePath, path.join(tempRoot, 'base-archive'));
|
|
58
|
+
const patchManifest = JSON.parse(fs.readFileSync(path.join(patchContents, BINARY_PATCH_MANIFEST_NAME), 'utf8'));
|
|
59
|
+
// Manifest paths are compared against archive-relative paths, which are POSIX.
|
|
60
|
+
const patchFile = normalizePath(patchManifest.patchFile);
|
|
61
|
+
const bundlePath = normalizePath(patchManifest.bundlePath);
|
|
62
|
+
// What the full archive of this release holds: the patch archive without its two
|
|
63
|
+
// patch-only files, plus the bundle the patch rebuilds. Deletions are worked out
|
|
64
|
+
// against that set, not against the subset the diff happens to ship.
|
|
65
|
+
const patchFiles = await listFilesRelative(patchContents);
|
|
66
|
+
const updateFiles = new Set(patchFiles.filter((relativePath) => relativePath !== BINARY_PATCH_MANIFEST_NAME && relativePath !== patchFile));
|
|
67
|
+
updateFiles.add(bundlePath);
|
|
68
|
+
// The archive root directory name is part of the contents layout, so the diff
|
|
69
|
+
// archive has to reuse the name the full archive used.
|
|
70
|
+
const contentsDirName = path.basename(patchContents);
|
|
71
|
+
const stagedContents = path.join(stagingRoot, contentsDirName);
|
|
72
|
+
fs.cpSync(patchContents, stagedContents, { recursive: true });
|
|
73
|
+
for (const relativePath of patchFiles) {
|
|
74
|
+
const baseFile = path.join(baseContents, relativePath);
|
|
75
|
+
if (fs.existsSync(baseFile) && sameBytes(path.join(patchContents, relativePath), baseFile)) {
|
|
76
|
+
fs.rmSync(path.join(stagedContents, relativePath));
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
const manifest = {
|
|
80
|
+
deletedFiles: (await listFilesRelative(baseContents))
|
|
81
|
+
.filter((relativePath) => !updateFiles.has(relativePath))
|
|
82
|
+
.map((relativePath) => `${contentsDirName}/${relativePath}`),
|
|
83
|
+
};
|
|
84
|
+
fs.writeFileSync(path.join(stagingRoot, ASSET_DIFF_MANIFEST_NAME), JSON.stringify(manifest, null, 2));
|
|
85
|
+
const diffArchiveZipPath = await zipDirectoryContents(stagingRoot);
|
|
86
|
+
if (fs.statSync(diffArchiveZipPath).size >= fs.statSync(patchBundleFilePath).size) {
|
|
87
|
+
// A diff that costs a client as much as the patch archive is never the better
|
|
88
|
+
// artifact of the two, and the release still publishes that patch archive.
|
|
89
|
+
fs.rmSync(diffArchiveZipPath);
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
const diffBundleFilePath = path.join(bundleDirectory, assetDiffArchiveName(packageHash, basePackageHash));
|
|
93
|
+
shell.mkdir('-p', bundleDirectory);
|
|
94
|
+
shell.mv(diffArchiveZipPath, diffBundleFilePath);
|
|
95
|
+
return { diffBundleFilePath };
|
|
96
|
+
}
|
|
97
|
+
finally {
|
|
98
|
+
fs.rmSync(tempRoot, { recursive: true, force: true });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/** Every file below `directoryPath`, as POSIX paths relative to it. */
|
|
102
|
+
async function listFilesRelative(directoryPath) {
|
|
103
|
+
const files = await walk(directoryPath);
|
|
104
|
+
return files.map((filePath) => normalizePath(path.relative(directoryPath, filePath)));
|
|
105
|
+
}
|
|
106
|
+
function sameBytes(left, right) {
|
|
107
|
+
// A base that holds a directory where the update holds a file has nothing the update
|
|
108
|
+
// can be spared from downloading, so the file ships.
|
|
109
|
+
const rightStats = fs.statSync(right);
|
|
110
|
+
if (!rightStats.isFile() || fs.statSync(left).size !== rightStats.size) {
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
return fs.readFileSync(left).equals(fs.readFileSync(right));
|
|
114
|
+
}
|