diff --git a/.idea/dictionaries/develar.xml b/.idea/dictionaries/develar.xml index 78c815b5f73..313ab6a74d8 100644 --- a/.idea/dictionaries/develar.xml +++ b/.idea/dictionaries/develar.xml @@ -199,6 +199,8 @@ repos revalidate rimraf + rollout + rollouts scripthost semver setfinderinfo @@ -232,6 +234,7 @@ unpage unref unwelcomefinishpage + userbase userland userprofile valuename diff --git a/docs/Auto Update.md b/docs/Auto Update.md index 42028ef8efb..da93b426e35 100644 --- a/docs/Auto Update.md +++ b/docs/Auto Update.md @@ -8,6 +8,7 @@ Simplified auto-update is supported on Windows if you use the default NSIS setup * Code signature validation not only on macOS, but also on Windows. * electron-builder produces and publishes all required metadata files and artifacts. * Download progress supported on all platforms, including macOS. +* [Staged rollouts](#staged-rollouts) supported on all platforms, including macOS. * Actually, built-in autoUpdater is used inside on macOS. * Different providers supported out of the box (GitHub, Bintray, Amazon S3, generic HTTP(s) server). * You need only 2 lines of code to make it work. @@ -39,6 +40,24 @@ Simplified auto-update is supported on Windows if you use the default NSIS setup * [Example in Typescript](https://github.com/develar/onshape-desktop-shell/blob/master/src/AppUpdater.ts) using system notifications. * An [encapsulated manual update via menu](https://github.com/electron-userland/electron-builder/blob/master/docs/encapsulated%20manual%20update%20via%20menu.js). +## Staged Rollouts + +Staged rollouts allow you to distribute the latest version of your app to a subset of users that you can increase over time, similar to rollouts on platforms like Google Play. + +Staged rollouts are controlled by manually editing your `latest.yml` / `latest-mac.yml` (channel update info file). + +```yml +version: 1.1.0 +path: TestApp Setup 1.1.0.exe +sha512: Dj51I0q8aPQ3ioaz9LMqGYujAYRbDNblAQbodDRXAMxmY6hsHqEl3F6SvhfJj5oPhcqdX1ldsgEvfMNXGUXBIw== +stagingPercentage: 10 +``` + +Update will be shipped to 10% of userbase. + +If you want to pull a staged release because it hasn't gone well, you **must** increment the version number higher than your broken release. +Because some of your users will be on the broken 1.0.1, releasing a new 1.0.1 would result in them staying on a broken version. + ## File Generated and Uploaded in Addition `latest.yml` (or `latest-mac.yml` for macOS) will be generated and uploaded for all providers except `bintray` (because not required, `bintray` doesn't use `latest.yml`). diff --git a/package.json b/package.json index e122ca45d45..f67467ed6a4 100644 --- a/package.json +++ b/package.json @@ -28,10 +28,10 @@ "///": "all dependencies for all packages (hoisted)", "dependencies": { "7zip-bin": "^2.1.0", - "ajv": "^5.1.6", + "ajv": "^5.2.0", "ajv-keywords": "^2.1.0", "archiver": "^1.3.0", - "aws-sdk": "^2.71.0", + "aws-sdk": "^2.72.0", "bluebird-lst": "^1.0.2", "chalk": "^1.1.3", "chromium-pickle-js": "^0.2.0", @@ -67,7 +67,7 @@ }, "devDependencies": { "@types/ini": "^1.3.29", - "@types/jest": "^19.2.4", + "@types/jest": "^20.0.0", "@types/js-yaml": "^3.5.31", "@types/node-forge": "^0.6.9", "@types/source-map-support": "^0.4.0", diff --git a/packages/electron-builder-http/src/updateInfo.ts b/packages/electron-builder-http/src/updateInfo.ts index 44465df720d..c06b30eac4a 100644 --- a/packages/electron-builder-http/src/updateInfo.ts +++ b/packages/electron-builder-http/src/updateInfo.ts @@ -31,4 +31,9 @@ export interface UpdateInfo extends VersionInfo { readonly sha2?: string readonly sha512?: string + + /** + * The [staged rollout](https://github.com/electron-userland/electron-builder/wiki/Auto-Update#staged-rollouts) percentage, 0-100. + */ + readonly stagingPercentage?: number } \ No newline at end of file diff --git a/packages/electron-builder/package.json b/packages/electron-builder/package.json index 15251488d58..95d738faa89 100644 --- a/packages/electron-builder/package.json +++ b/packages/electron-builder/package.json @@ -50,7 +50,7 @@ "homepage": "https://github.com/electron-userland/electron-builder", "dependencies": { "7zip-bin": "^2.1.0", - "ajv": "^5.1.6", + "ajv": "^5.2.0", "ajv-keywords": "^2.1.0", "bluebird-lst": "^1.0.2", "chalk": "^1.1.3", diff --git a/packages/electron-publisher-s3/package.json b/packages/electron-publisher-s3/package.json index f417554e7f5..94c44c3351c 100644 --- a/packages/electron-publisher-s3/package.json +++ b/packages/electron-publisher-s3/package.json @@ -12,7 +12,7 @@ ], "dependencies": { "fs-extra-p": "^4.3.0", - "aws-sdk": "^2.70.0", + "aws-sdk": "^2.72.0", "mime": "^1.3.6", "electron-publish": "~0.0.0-semantic-release", "electron-builder-util": "~0.0.0-semantic-release" diff --git a/packages/electron-updater/README.md b/packages/electron-updater/README.md index 4dbb373de04..83fca4db783 100644 --- a/packages/electron-updater/README.md +++ b/packages/electron-updater/README.md @@ -18,6 +18,7 @@ Linux support is [planned](https://github.com/electron-userland/electron-builder * Code signature validation on Windows. * [electron-builder](https://github.com/electron-userland/electron-builder) produces and publishes all required metadata files and artifacts. * Download progress supported on all platforms, including macOS. +* [Staged rollouts](https://github.com/electron-userland/electron-builder/wiki/Auto-Update#staged-rollouts) supported on all platforms, including macOS. * Actually, built-in autoUpdater is used inside on macOS. * Different providers supported out of the box (GitHub, Bintray, Amazon S3, generic HTTP(s) server). * You need only 2 lines of code to make it work. diff --git a/packages/electron-updater/src/AppUpdater.ts b/packages/electron-updater/src/AppUpdater.ts index a7c30df5a36..b75e472433c 100644 --- a/packages/electron-updater/src/AppUpdater.ts +++ b/packages/electron-updater/src/AppUpdater.ts @@ -3,7 +3,7 @@ import { randomBytes } from "crypto" import { RequestHeaders } from "electron-builder-http" import { CancellationToken } from "electron-builder-http/out/CancellationToken" import { BintrayOptions, GenericServerOptions, GithubOptions, PublishConfiguration, S3Options, s3Url } from "electron-builder-http/out/publishOptions" -import { VersionInfo } from "electron-builder-http/out/updateInfo" +import { UpdateInfo, VersionInfo } from "electron-builder-http/out/updateInfo" import { EventEmitter } from "events" import { outputFile, readFile } from "fs-extra-p" import { safeLoad } from "js-yaml" @@ -77,7 +77,7 @@ export abstract class AppUpdater extends EventEmitter { protected get stagingUserIdPromise(): Promise { let result = this._stagingUserIdPromise if (result == null) { - result = this.getOrCreateStagedUserId() + result = this.getOrCreateStagingUserId() this._stagingUserIdPromise = result } return result @@ -173,6 +173,29 @@ export abstract class AppUpdater extends EventEmitter { return checkForUpdatesPromise } + private async isStagingMatch(updateInfo: UpdateInfo): Promise { + const rawStagingPercentage = updateInfo.stagingPercentage + let stagingPercentage = rawStagingPercentage + if (stagingPercentage == null) { + return true + } + + stagingPercentage = parseInt(stagingPercentage, 10) + if (isNaN(stagingPercentage)) { + this._logger.warn(`Staging percentage is NaN: ${rawStagingPercentage}`) + return true + } + + // convert from user 0-100 to internal 0-1 + stagingPercentage = stagingPercentage / 100 + + const stagingUserId = await this.stagingUserIdPromise + const val = UUID.parse(stagingUserId).readUInt32BE(12) + const percentage = (val / 0xFFFFFFFF) + this._logger.info(`Staging percentage: ${stagingPercentage}, percentage: ${percentage}, user id: ${stagingUserId}`) + return percentage < stagingPercentage + } + private async _checkForUpdates(): Promise { try { await this.untilAppReady @@ -201,7 +224,8 @@ export abstract class AppUpdater extends EventEmitter { throw new Error(`Latest version (from update server) is not valid semver version: "${latestVersion}`) } - if (this.allowDowngrade && !hasPrereleaseComponents(latestVersion) ? isVersionsEqual(latestVersion, this.currentVersion) : !isVersionGreaterThan(latestVersion, this.currentVersion)) { + const isStagingMatch = await this.isStagingMatch(versionInfo) + if (!isStagingMatch || (this.allowDowngrade && !hasPrereleaseComponents(latestVersion) ? isVersionsEqual(latestVersion, this.currentVersion) : !isVersionGreaterThan(latestVersion, this.currentVersion))) { this.updateAvailable = false this._logger.info(`Update for version ${this.currentVersion} is not available (latest version: ${versionInfo.version}, downgrade is ${this.allowDowngrade ? "allowed" : "disallowed"}.`) this.emit("update-not-available", versionInfo) @@ -328,7 +352,7 @@ export abstract class AppUpdater extends EventEmitter { } } - private async getOrCreateStagedUserId(): Promise { + private async getOrCreateStagingUserId(): Promise { const file = path.join(this.app.getPath("userData"), ".updaterId") try { const id = await readFile(file, "utf-8") diff --git a/test/out/__snapshots__/nsisUpdaterTest.js.snap b/test/out/__snapshots__/nsisUpdaterTest.js.snap index cff44dee733..753a871edaa 100644 --- a/test/out/__snapshots__/nsisUpdaterTest.js.snap +++ b/test/out/__snapshots__/nsisUpdaterTest.js.snap @@ -1,5 +1,30 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`1 staging percentage 1`] = `undefined`; + +exports[`1 staging percentage 2`] = ` +Array [ + "checking-for-update", +] +`; + +exports[`90 staging percentage 1`] = ` +Object { + "name": "TestApp Setup 1.1.0.exe", + "sha2": undefined, + "sha512": "Dj51I0q8aPQ3ioaz9LMqGYujAYRbDNblAQbodDRXAMxmY6hsHqEl3F6SvhfJj5oPhcqdX1ldsgEvfMNXGUXBIw==", + "url": "https://develar.s3.amazonaws.com/test/TestApp Setup 1.1.0.exe", +} +`; + +exports[`90 staging percentage 2`] = ` +Array [ + "checking-for-update", + "update-available", + "update-downloaded", +] +`; + exports[`cancel download with progress 1`] = `"Cancelled"`; exports[`check updates - no versions at all 1`] = `"No latest version, please ensure that user, package and repository correctly configured. Or at least one version is published. HttpError: 404 Not Found"`; diff --git a/test/src/nsisUpdaterTest.ts b/test/src/nsisUpdaterTest.ts index 43576549843..549d762a127 100644 --- a/test/src/nsisUpdaterTest.ts +++ b/test/src/nsisUpdaterTest.ts @@ -242,13 +242,18 @@ test.skip("file url github private", async () => { await validateDownload(updater) }) -async function validateDownload(updater: NsisUpdater) { +async function validateDownload(updater: NsisUpdater, expectDownloadPromise = true) { tuneNsisUpdater(updater) const actualEvents = trackEvents(updater) const updateCheckResult = await updater.checkForUpdates() expect(updateCheckResult.fileInfo).toMatchSnapshot() - await assertThat(path.join(await updateCheckResult.downloadPromise)).isFile() + if (expectDownloadPromise) { + await assertThat(path.join(await updateCheckResult.downloadPromise)).isFile() + } + else { + expect(updateCheckResult.downloadPromise).toBeUndefined() + } expect(actualEvents).toMatchSnapshot() return updateCheckResult @@ -313,6 +318,34 @@ test.ifAll.ifWindows("invalid signature", async () => { expect(actualEvents).toMatchSnapshot() }) +test("90 staging percentage", async () => { + const userIdFile = path.join(tmpdir(), "electron-updater-test", "userData", ".updaterId") + await outputFile(userIdFile, "12a70172-80f8-5cc4-8131-28f5e0edd2a1") + + const updater = new NsisUpdater() + updater.updateConfigPath = await writeUpdateConfig({ + provider: "s3", + channel: "staging-percentage", + bucket: "develar", + path: "test", + }) + await validateDownload(updater, false) +}) + +test("1 staging percentage", async () => { + const userIdFile = path.join(tmpdir(), "electron-updater-test", "userData", ".updaterId") + await outputFile(userIdFile, "12a70172-80f8-5cc4-8131-28f5e0edd2a1") + + const updater = new NsisUpdater() + updater.updateConfigPath = await writeUpdateConfig({ + provider: "s3", + channel: "staging-percentage-small", + bucket: "develar", + path: "test", + }) + await validateDownload(updater, false) +}) + test("cancel download with progress", async () => { const updater = new NsisUpdater() updater.updateConfigPath = await writeUpdateConfig({ diff --git a/yarn.lock b/yarn.lock index 51833dfdbad..70ae23a3780 100644 --- a/yarn.lock +++ b/yarn.lock @@ -26,9 +26,9 @@ version "1.3.29" resolved "https://registry.yarnpkg.com/@types/ini/-/ini-1.3.29.tgz#1325e981e047d40d13ce0359b821475b97741d2f" -"@types/jest@^19.2.4": - version "19.2.4" - resolved "https://registry.yarnpkg.com/@types/jest/-/jest-19.2.4.tgz#543651712535962b7dc615e18e4a381fc2687442" +"@types/jest@^20.0.0": + version "20.0.0" + resolved "https://registry.yarnpkg.com/@types/jest/-/jest-20.0.0.tgz#f7119f92891e150b33d67505cdd6d95585156133" "@types/js-yaml@^3.5.31": version "3.5.31" @@ -91,11 +91,12 @@ ajv@^4.9.1: co "^4.6.0" json-stable-stringify "^1.0.1" -ajv@^5.1.6: - version "5.1.6" - resolved "https://registry.yarnpkg.com/ajv/-/ajv-5.1.6.tgz#4b2f1a19dece93d57ac216037e3e9791c7dd1564" +ajv@^5.2.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-5.2.0.tgz#c1735024c5da2ef75cc190713073d44f098bf486" dependencies: co "^4.6.0" + fast-deep-equal "^0.1.0" json-schema-traverse "^0.3.0" json-stable-stringify "^1.0.1" @@ -266,9 +267,9 @@ asynckit@^0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" -aws-sdk@^2.71.0: - version "2.71.0" - resolved "https://registry.yarnpkg.com/aws-sdk/-/aws-sdk-2.71.0.tgz#629e49d48a95874b77ed50ec9b621633ec83a8ef" +aws-sdk@^2.72.0: + version "2.72.0" + resolved "https://registry.yarnpkg.com/aws-sdk/-/aws-sdk-2.72.0.tgz#59021c14e354f34a4fb4f229ac10f8e36428f4d4" dependencies: buffer "5.0.6" crypto-browserify "1.0.9" @@ -546,6 +547,10 @@ binary@^0.3.0: buffers "~0.1.1" chainsaw "~0.1.0" +bit-buffer@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/bit-buffer/-/bit-buffer-0.1.0.tgz#8164c15dbd218eea74e0843da70efa555a4402c4" + bl@^1.0.0: version "1.2.1" resolved "https://registry.yarnpkg.com/bl/-/bl-1.2.1.tgz#cac328f7bee45730d404b692203fcb590e172d5e" @@ -816,7 +821,7 @@ compare-version@^0.1.2: version "0.1.2" resolved "https://registry.yarnpkg.com/compare-version/-/compare-version-0.1.2.tgz#0162ec2d9351f5ddd59a9202cba935366a725080" -compress-commons@^1.1.0: +compress-commons@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/compress-commons/-/compress-commons-1.2.0.tgz#58587092ef20d37cb58baf000112c9278ff73b9f" dependencies: @@ -1212,6 +1217,10 @@ extsprintf@1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.0.2.tgz#e1080e0658e300b06294990cc70e1502235fd550" +fast-deep-equal@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-0.1.0.tgz#5c6f4599aba6b333ee3342e2ed978672f1001f8d" + fast-levenshtein@~2.0.4: version "2.0.6" resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917" @@ -3748,10 +3757,10 @@ yargs@~3.10.0: window-size "0.1.0" zip-stream@^1.1.0: - version "1.1.1" - resolved "https://registry.yarnpkg.com/zip-stream/-/zip-stream-1.1.1.tgz#5216b48bbb4d2651f64d5c6e6f09eb4a7399d557" + version "1.2.0" + resolved "https://registry.yarnpkg.com/zip-stream/-/zip-stream-1.2.0.tgz#a8bc45f4c1b49699c6b90198baacaacdbcd4ba04" dependencies: archiver-utils "^1.3.0" - compress-commons "^1.1.0" + compress-commons "^1.2.0" lodash "^4.8.0" readable-stream "^2.0.0"