import { Arch, Fields, httpExecutor, InvalidConfigurationError, isEmptyOrSpaces, isEnvTrue, isTokenCharValid, log } from "builder-util" import { configureRequestOptions, GithubOptions, HttpError, parseJson } from "builder-util-runtime" import { ClientRequest } from "http" import { Lazy } from "lazy-val" import * as mime from "mime" import { parse as parseUrl, UrlWithStringQuery } from "url" import { HttpPublisher } from "./httpPublisher" import { PublishContext, PublishOptions } from "./index" import { getCiTag } from "./publisher" export interface Release { id: number tag_name: string draft: boolean prerelease: boolean published_at: string upload_url: string } interface Asset { id: number name: string } export class GitHubPublisher extends HttpPublisher { private readonly tag: string readonly _release = new Lazy(() => (this.token === "__test__" ? Promise.resolve(null as any) : this.getOrCreateRelease())) private readonly token: string readonly providerName = "github" private readonly releaseType: "draft" | "prerelease" | "release" private releaseLogFields: Fields | null = null constructor( context: PublishContext, private readonly info: GithubOptions, private readonly version: string, private readonly options: PublishOptions = {} ) { super(context, true) let token = info.token if (isEmptyOrSpaces(token) || process.env.GITHUB_RELEASE_TOKEN) { token = process.env.GITHUB_RELEASE_TOKEN ? process.env.GITHUB_RELEASE_TOKEN : process.env.GH_TOKEN || process.env.GITHUB_TOKEN if (isEmptyOrSpaces(token)) { throw new InvalidConfigurationError(`GitHub Personal Access Token is not set, neither programmatically, nor using env "GH_TOKEN"`) } token = token.trim() if (!isTokenCharValid(token)) { throw new InvalidConfigurationError(`GitHub Personal Access Token (${JSON.stringify(token)}) contains invalid characters, please check env "GH_TOKEN"`) } } this.token = token if (version.startsWith("v")) { throw new InvalidConfigurationError(`Version must not start with "v": ${version}`) } this.tag = info.vPrefixedTagName === false ? version : `v${version}` if (isEnvTrue(process.env.EP_DRAFT)) { this.releaseType = "draft" log.info({ reason: "env EP_DRAFT is set to true" }, "GitHub provider release type is set to draft") } else if (isEnvTrue(process.env.EP_PRE_RELEASE) || isEnvTrue(process.env.EP_PRELEASE) /* https://github.com/electron-userland/electron-builder/issues/2878 */) { this.releaseType = "prerelease" log.info({ reason: "env EP_PRE_RELEASE is set to true" }, "GitHub provider release type is set to prerelease") } else if (info.releaseType != null) { this.releaseType = info.releaseType } else if ((options as any).prerelease) { this.releaseType = "prerelease" } else { // noinspection PointlessBooleanExpressionJS this.releaseType = (options as any).draft === false ? "release" : "draft" } } private async getOrCreateRelease(): Promise { const logFields = { tag: this.tag, version: this.version, } // we don't use "Get a release by tag name" because "tag name" means existing git tag, but we draft release and don't create git tag const releases = await this.githubRequest>(`/repos/${this.info.owner}/${this.info.repo}/releases`, this.token) for (const release of releases) { if (!(release.tag_name === this.tag || release.tag_name === this.version)) { continue } if (release.draft) { return release } // https://github.com/electron-userland/electron-builder/issues/1197 // https://github.com/electron-userland/electron-builder/issues/2072 if (this.releaseType === "draft") { this.releaseLogFields = { reason: "existing type not compatible with publishing type", ...logFields, existingType: release.prerelease ? "pre-release" : "release", publishingType: this.releaseType, } log.warn(this.releaseLogFields, "GitHub release not created") return null } // https://github.com/electron-userland/electron-builder/issues/1133 // https://github.com/electron-userland/electron-builder/issues/2074 // if release created < 2 hours — allow to upload const publishedAt = release.published_at == null ? null : Date.parse(release.published_at) if (!isEnvTrue(process.env.EP_GH_IGNORE_TIME) && publishedAt != null && Date.now() - publishedAt > 2 * 3600 * 1000) { // https://github.com/electron-userland/electron-builder/issues/1183#issuecomment-275867187 this.releaseLogFields = { reason: "existing release published more than 2 hours ago", ...logFields, date: new Date(publishedAt).toString(), } log.warn(this.releaseLogFields, "GitHub release not created") return null } return release } // https://github.com/electron-userland/electron-builder/issues/1835 if (this.options.publish === "always" || getCiTag() != null) { log.info( { reason: "release doesn't exist", ...logFields, }, `creating GitHub release` ) return this.createRelease() } this.releaseLogFields = { reason: 'release doesn\'t exist and not created because "publish" is not "always" and build is not on tag', ...logFields, } return null } private async overwriteArtifact(fileName: string, release: Release) { // delete old artifact and re-upload log.warn({ file: fileName, reason: "already exists on GitHub" }, "overwrite published file") const assets = await this.githubRequest>(`/repos/${this.info.owner}/${this.info.repo}/releases/${release.id}/assets`, this.token, null) for (const asset of assets) { if (asset.name === fileName) { await this.githubRequest(`/repos/${this.info.owner}/${this.info.repo}/releases/assets/${asset.id}`, this.token, null, "DELETE") return } } log.debug({ file: fileName, reason: "not found on GitHub" }, "trying to upload again") } protected async doUpload(fileName: string, arch: Arch, dataLength: number, requestProcessor: (request: ClientRequest, reject: (error: Error) => void) => void): Promise { const release = await this._release.value if (release == null) { log.warn({ file: fileName, ...this.releaseLogFields }, "skipped publishing") return } const parsedUrl = parseUrl(`${release.upload_url.substring(0, release.upload_url.indexOf("{"))}?name=${fileName}`) return await this.doUploadFile(0, parsedUrl, fileName, dataLength, requestProcessor, release) } private doUploadFile( attemptNumber: number, parsedUrl: UrlWithStringQuery, fileName: string, dataLength: number, requestProcessor: (request: ClientRequest, reject: (error: Error) => void) => void, release: any ): Promise { return httpExecutor .doApiRequest( configureRequestOptions( { protocol: parsedUrl.protocol, hostname: parsedUrl.hostname, path: parsedUrl.path, method: "POST", headers: { accept: "application/vnd.github.v3+json", "Content-Type": mime.getType(fileName) || "application/octet-stream", "Content-Length": dataLength, }, timeout: this.info.timeout || undefined, }, this.token ), this.context.cancellationToken, requestProcessor ) .catch((e: any) => { if (attemptNumber > 3) { return Promise.reject(e) } else if (this.doesErrorMeanAlreadyExists(e)) { return this.overwriteArtifact(fileName, release).then(() => this.doUploadFile(attemptNumber + 1, parsedUrl, fileName, dataLength, requestProcessor, release)) } else { return new Promise((resolve, reject) => { const newAttemptNumber = attemptNumber + 1 setTimeout(() => { this.doUploadFile(newAttemptNumber, parsedUrl, fileName, dataLength, requestProcessor, release).then(resolve).catch(reject) }, newAttemptNumber * 2000) }) } }) } private doesErrorMeanAlreadyExists(e: any) { if (!e.description) { return false } const desc = e.description const descIncludesAlreadyExists = (desc.includes("errors") && desc.includes("already_exists")) || (desc.errors && desc.errors.length >= 1 && desc.errors[0].code === "already_exists") return e.statusCode === 422 && descIncludesAlreadyExists } private createRelease() { return this.githubRequest(`/repos/${this.info.owner}/${this.info.repo}/releases`, this.token, { tag_name: this.tag, name: this.version, draft: this.releaseType === "draft", prerelease: this.releaseType === "prerelease", }) } // test only //noinspection JSUnusedGlobalSymbols async getRelease(): Promise { return this.githubRequest(`/repos/${this.info.owner}/${this.info.repo}/releases/${(await this._release.value)!.id}`, this.token) } //noinspection JSUnusedGlobalSymbols async deleteRelease(): Promise { if (!this._release.hasValue) { return } const release = await this._release.value for (let i = 0; i < 3; i++) { try { return await this.githubRequest(`/repos/${this.info.owner}/${this.info.repo}/releases/${release.id}`, this.token, null, "DELETE") } catch (e: any) { if (e instanceof HttpError) { if (e.statusCode === 404) { log.warn({ releaseId: release.id, reason: "doesn't exist" }, "cannot delete release") return } else if (e.statusCode === 405 || e.statusCode === 502) { continue } } throw e } } log.warn({ releaseId: release.id }, "cannot delete release") } private githubRequest(path: string, token: string | null, data: { [name: string]: any } | null = null, method?: "GET" | "DELETE" | "PUT"): Promise { // host can contains port, but node http doesn't support host as url does const baseUrl = parseUrl(`https://${this.info.host || "api.github.com"}`) return parseJson( httpExecutor.request( configureRequestOptions( { protocol: baseUrl.protocol, hostname: baseUrl.hostname, port: baseUrl.port as any, path: this.info.host != null && this.info.host !== "github.com" ? `/api/v3${path.startsWith("/") ? path : `/${path}`}` : path, headers: { accept: "application/vnd.github.v3+json" }, timeout: this.info.timeout || undefined, }, token, method ), this.context.cancellationToken, data ) ) } toString() { return `Github (owner: ${this.info.owner}, project: ${this.info.repo}, version: ${this.version})` } }