-
-
Notifications
You must be signed in to change notification settings - Fork 1.7k
/
GitHubProvider.ts
211 lines (183 loc) · 8.27 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
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
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 = null
try {
if (this.updater.allowPrerelease) {
const currentChannel = this.updater?.channel || String(semver.prerelease(this.updater.currentVersion)?.[0]) || null
for (const element of feed.getElements("entry")) {
// noinspection TypeScriptValidateJSTypes
const hrefElement = hrefRegExp.exec(element.element("link").attribute("href"))!
// If this is null then something is wrong and skip this release
if (hrefElement === null) continue
// This Release's Tag
const hrefTag = hrefElement[1]
//Get Channel from this release's tag
const hrefChannel = semver.prerelease(hrefTag)?.[0] || null
const shouldFetchVersion = !currentChannel || ["alpha", "beta"].includes(currentChannel)
const isCustomChannel = !["alpha", "beta"].includes(String(hrefChannel))
// Allow moving from alpha to beta but not down
const channelMismatch = currentChannel === "beta" && hrefChannel === "alpha"
if (shouldFetchVersion && !isCustomChannel && !channelMismatch) {
tag = hrefTag
break
}
const isNextPreRelease = hrefChannel && hrefChannel === currentChannel
if (isNextPreRelease) {
tag = hrefTag
break
}
}
} 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")
}
let rawData: string
let channelFile: string = ""
let channelFileUrl: any = ""
const fetchData = async (channelName: string) => {
channelFile = getChannelFilename(channelName)
channelFileUrl = newUrlFromBase(this.getBaseDownloadPath(String(tag), channelFile), this.baseUrl)
const requestOptions = this.createRequestOptions(channelFileUrl)
try {
return (await this.executor.request(requestOptions, cancellationToken))!
} catch (e) {
if (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
}
}
try {
const channel = this.updater.allowPrerelease ? this.getCustomChannelName(String(semver.prerelease(tag)?.[0] || "latest")) : this.getDefaultChannelName()
rawData = await fetchData(channel)
} catch (e) {
if (this.updater.allowPrerelease) {
// Allow fallback to `latest.yml`
rawData = await fetchData(this.getDefaultChannelName())
} else {
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))
}