/
GitHubProvider.ts
168 lines (146 loc) · 6.63 KB
/
GitHubProvider.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
import { CancellationToken, GithubOptions, githubUrl, HttpError, newError, parseXml, ReleaseNoteInfo, UpdateInfo, XElement } from "builder-util-runtime"
import * as semver from "semver"
import { URL } from "url"
import { AppUpdater } from "../AppUpdater"
import { ResolvedUpdateFileInfo } from "../main"
import { getChannelFilename, newBaseUrl, newUrlFromBase } from "../util"
import { parseUpdateInfo, Provider, ProviderRuntimeOptions, resolveFiles } from "./Provider"
const hrefRegExp = /\/tag\/([^/]+)$/
interface GithubUpdateInfo extends UpdateInfo {
tag: string
}
export abstract class BaseGitHubProvider<T extends UpdateInfo> extends Provider<T> {
// so, we don't need to parse port (because node http doesn't support host as url does)
protected readonly baseUrl: URL
protected readonly baseApiUrl: URL
protected constructor(protected readonly options: GithubOptions, defaultHost: string, runtimeOptions: ProviderRuntimeOptions) {
super({
...runtimeOptions,
/* because GitHib uses S3 */
isUseMultipleRangeRequest: false,
})
this.baseUrl = newBaseUrl(githubUrl(options, defaultHost))
const apiHost = defaultHost === "github.com" ? "api.github.com" : defaultHost
this.baseApiUrl = newBaseUrl(githubUrl(options, apiHost))
}
protected computeGithubBasePath(result: string): string {
// https://github.com/electron-userland/electron-builder/issues/1903#issuecomment-320881211
const host = this.options.host
return host != null && host !== "github.com" && host !== "api.github.com" ? `/api/v3${result}` : result
}
}
export class GitHubProvider extends BaseGitHubProvider<GithubUpdateInfo> {
constructor(protected readonly options: GithubOptions, private readonly updater: AppUpdater, runtimeOptions: ProviderRuntimeOptions) {
super(options, "github.com", runtimeOptions)
}
async getLatestVersion(): Promise<GithubUpdateInfo> {
const cancellationToken = new CancellationToken()
const feedXml: string = (await this.httpRequest(
newUrlFromBase(`${this.basePath}.atom`, this.baseUrl),
{
accept: "application/xml, application/atom+xml, text/xml, */*",
},
cancellationToken
))!
const feed = parseXml(feedXml)
// noinspection TypeScriptValidateJSTypes
let latestRelease = feed.element("entry", false, `No published versions on GitHub`)
let tag: string | null
try {
if (this.updater.allowPrerelease) {
// noinspection TypeScriptValidateJSTypes
tag = hrefRegExp.exec(latestRelease.element("link").attribute("href"))![1]
} else {
tag = await this.getLatestTagName(cancellationToken)
for (const element of feed.getElements("entry")) {
// noinspection TypeScriptValidateJSTypes
if (hrefRegExp.exec(element.element("link").attribute("href"))![1] === tag) {
latestRelease = element
break
}
}
}
} catch (e) {
throw newError(`Cannot parse releases feed: ${e.stack || e.message},\nXML:\n${feedXml}`, "ERR_UPDATER_INVALID_RELEASE_FEED")
}
if (tag == null) {
throw newError(`No published versions on GitHub`, "ERR_UPDATER_NO_PUBLISHED_VERSIONS")
}
const channelFile = getChannelFilename(this.getDefaultChannelName())
const channelFileUrl = newUrlFromBase(this.getBaseDownloadPath(tag, channelFile), this.baseUrl)
const requestOptions = this.createRequestOptions(channelFileUrl)
let rawData: string
try {
rawData = (await this.executor.request(requestOptions, cancellationToken))!
} catch (e) {
if (!this.updater.allowPrerelease && e instanceof HttpError && e.statusCode === 404) {
throw newError(`Cannot find ${channelFile} in the latest release artifacts (${channelFileUrl}): ${e.stack || e.message}`, "ERR_UPDATER_CHANNEL_FILE_NOT_FOUND")
}
throw e
}
const result = parseUpdateInfo(rawData, channelFile, channelFileUrl)
if (result.releaseName == null) {
result.releaseName = latestRelease.elementValueOrEmpty("title")
}
if (result.releaseNotes == null) {
result.releaseNotes = computeReleaseNotes(this.updater.currentVersion, this.updater.fullChangelog, feed, latestRelease)
}
return {
tag: tag,
...result,
}
}
private async getLatestTagName(cancellationToken: CancellationToken): Promise<string | null> {
const options = this.options
// do not use API for GitHub to avoid limit, only for custom host or GitHub Enterprise
const url =
options.host == null || options.host === "github.com"
? newUrlFromBase(`${this.basePath}/latest`, this.baseUrl)
: new URL(`${this.computeGithubBasePath(`/repos/${options.owner}/${options.repo}/releases`)}/latest`, this.baseApiUrl)
try {
const rawData = await this.httpRequest(url, { Accept: "application/json" }, cancellationToken)
if (rawData == null) {
return null
}
const releaseInfo: GithubReleaseInfo = JSON.parse(rawData)
return releaseInfo.tag_name
} catch (e) {
throw newError(`Unable to find latest version on GitHub (${url}), please ensure a production release exists: ${e.stack || e.message}`, "ERR_UPDATER_LATEST_VERSION_NOT_FOUND")
}
}
private get basePath(): string {
return `/${this.options.owner}/${this.options.repo}/releases`
}
resolveFiles(updateInfo: GithubUpdateInfo): Array<ResolvedUpdateFileInfo> {
// still replace space to - due to backward compatibility
return resolveFiles(updateInfo, this.baseUrl, p => this.getBaseDownloadPath(updateInfo.tag, p.replace(/ /g, "-")))
}
private getBaseDownloadPath(tag: string, fileName: string): string {
return `${this.basePath}/download/${tag}/${fileName}`
}
}
interface GithubReleaseInfo {
readonly tag_name: string
}
function getNoteValue(parent: XElement): string {
const result = parent.elementValueOrEmpty("content")
// GitHub reports empty notes as <content>No content.</content>
return result === "No content." ? "" : result
}
export function computeReleaseNotes(currentVersion: semver.SemVer, isFullChangelog: boolean, feed: XElement, latestRelease: any): string | Array<ReleaseNoteInfo> | null {
if (!isFullChangelog) {
return getNoteValue(latestRelease)
}
const releaseNotes: Array<ReleaseNoteInfo> = []
for (const release of feed.getElements("entry")) {
// noinspection TypeScriptValidateJSTypes
const versionRelease = /\/tag\/v?([^/]+)$/.exec(release.element("link").attribute("href"))![1]
if (semver.lt(currentVersion, versionRelease)) {
releaseNotes.push({
version: versionRelease,
note: getNoteValue(release),
})
}
}
return releaseNotes.sort((a, b) => semver.rcompare(a.version, b.version))
}