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) ) {
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 {
this.releaseType = (options as any).draft === false ? "release" : "draft"
}
}
private async getOrCreateRelease(): Promise<Release | null> {
const logFields = {
tag: this.tag,
version: this.version,
}
const releases = await this.githubRequest<Array<Release>>(`/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
}
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
}
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) {
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
}
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) {
log.warn({ file: fileName, reason: "already exists on GitHub" }, "overwrite published file")
const assets = await this.githubRequest<Array<Asset>>(`/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<void>(`/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<any> {
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<any> {
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<Release>(`/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",
})
}
async getRelease(): Promise<any> {
return this.githubRequest<Release>(`/repos/${this.info.owner}/${this.info.repo}/releases/${(await this._release.value)!.id}`, this.token)
}
async deleteRelease(): Promise<any> {
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<T>(path: string, token: string | null, data: { [name: string]: any } | null = null, method?: "GET" | "DELETE" | "PUT"): Promise<T> {
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})`
}
}