Skip to content

Commit

Permalink
feat(electron-updater): staged rollouts
Browse files Browse the repository at this point in the history
Close #1639
  • Loading branch information
develar committed Jun 17, 2017
1 parent c5d3441 commit 5bae61e
Show file tree
Hide file tree
Showing 11 changed files with 143 additions and 24 deletions.
3 changes: 3 additions & 0 deletions .idea/dictionaries/develar.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

19 changes: 19 additions & 0 deletions docs/Auto Update.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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`).
Expand Down
6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
5 changes: 5 additions & 0 deletions packages/electron-builder-http/src/updateInfo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
2 changes: 1 addition & 1 deletion packages/electron-builder/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion packages/electron-publisher-s3/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions packages/electron-updater/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
32 changes: 28 additions & 4 deletions packages/electron-updater/src/AppUpdater.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -77,7 +77,7 @@ export abstract class AppUpdater extends EventEmitter {
protected get stagingUserIdPromise(): Promise<string> {
let result = this._stagingUserIdPromise
if (result == null) {
result = this.getOrCreateStagedUserId()
result = this.getOrCreateStagingUserId()
this._stagingUserIdPromise = result
}
return result
Expand Down Expand Up @@ -173,6 +173,29 @@ export abstract class AppUpdater extends EventEmitter {
return checkForUpdatesPromise
}

private async isStagingMatch(updateInfo: UpdateInfo): Promise<boolean> {
const rawStagingPercentage = updateInfo.stagingPercentage
let stagingPercentage = rawStagingPercentage
if (stagingPercentage == null) {
return true
}

stagingPercentage = parseInt(<any>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<UpdateCheckResult> {
try {
await this.untilAppReady
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -328,7 +352,7 @@ export abstract class AppUpdater extends EventEmitter {
}
}

private async getOrCreateStagedUserId(): Promise<string> {
private async getOrCreateStagingUserId(): Promise<string> {
const file = path.join(this.app.getPath("userData"), ".updaterId")
try {
const id = await readFile(file, "utf-8")
Expand Down
25 changes: 25 additions & 0 deletions test/out/__snapshots__/nsisUpdaterTest.js.snap
Original file line number Diff line number Diff line change
@@ -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"`;
Expand Down
37 changes: 35 additions & 2 deletions test/src/nsisUpdaterTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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({
Expand Down
35 changes: 22 additions & 13 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"

Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -1212,6 +1217,10 @@ [email protected]:
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"
Expand Down Expand Up @@ -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"

0 comments on commit 5bae61e

Please sign in to comment.