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"