diff --git a/.changeset/seven-frogs-attend.md b/.changeset/seven-frogs-attend.md new file mode 100644 index 0000000000..2d31aaaa2f --- /dev/null +++ b/.changeset/seven-frogs-attend.md @@ -0,0 +1,6 @@ +--- +"app-builder-lib": minor +"electron-updater": minor +--- + +Upgrade Keygen publisher/updater integration to API version v1.1. diff --git a/packages/app-builder-lib/src/publish/KeygenPublisher.ts b/packages/app-builder-lib/src/publish/KeygenPublisher.ts index 6f17d8d474..5aa47b229c 100644 --- a/packages/app-builder-lib/src/publish/KeygenPublisher.ts +++ b/packages/app-builder-lib/src/publish/KeygenPublisher.ts @@ -6,6 +6,77 @@ import { KeygenOptions } from "builder-util-runtime/out/publishOptions" import { configureRequestOptions, HttpExecutor, parseJson } from "builder-util-runtime" import { getCompleteExtname } from "../util/filename" +type RecursivePartial = { + [P in keyof T]?: RecursivePartial +} + +export interface KeygenError { + title: string + detail: string + code: string +} + +export interface KeygenRelease { + id: string + type: "releases" + attributes: { + name: string | null + description: string | null + channel: "stable" | "rc" | "beta" | "alpha" | "dev" + status: "DRAFT" | "PUBLISHED" | "YANKED" + tag: string + version: string + semver: { + major: number + minor: number + patch: number + prerelease: string | null + build: string | null + } + metadata: { [s: string]: any } + created: string + updated: string + yanked: string | null + } + relationships: { + account: { + data: { type: "accounts"; id: string } + } + product: { + data: { type: "products"; id: string } + } + } +} + +export interface KeygenArtifact { + id: string + type: "artifacts" + attributes: { + filename: string + filetype: string | null + filesize: number | null + platform: string | null + arch: string | null + signature: string | null + checksum: string | null + status: "WAITING" | "UPLOADED" | "FAILED" | "YANKED" + metadata: { [s: string]: any } + created: string + updated: string + } + relationships: { + account: { + data: { type: "accounts"; id: string } + } + release: { + data: { type: "releases"; id: string } + } + } + links: { + redirect: string + } +} + export class KeygenPublisher extends HttpPublisher { readonly providerName = "keygen" readonly hostname = "api.keygen.sh" @@ -26,7 +97,7 @@ export class KeygenPublisher extends HttpPublisher { this.info = info this.auth = `Bearer ${token.trim()}` this.version = version - this.basePath = `/v1/accounts/${this.info.account}/releases` + this.basePath = `/v1/accounts/${this.info.account}` } protected doUpload( @@ -36,78 +107,143 @@ export class KeygenPublisher extends HttpPublisher { requestProcessor: (request: ClientRequest, reject: (error: Error) => void) => void, // eslint-disable-next-line @typescript-eslint/no-unused-vars _file: string - ): Promise { + ): Promise { return HttpExecutor.retryOnServerError(async () => { - const { data, errors } = await this.upsertRelease(fileName, dataLength) + const { data, errors } = await this.getOrCreateRelease() if (errors) { - throw new Error(`Keygen - Upserting release returned errors: ${JSON.stringify(errors)}`) + throw new Error(`Keygen - Creating release returned errors: ${JSON.stringify(errors)}`) } - const releaseId = data?.id - if (!releaseId) { - log.warn({ file: fileName, reason: "UUID doesn't exist and was not created" }, "upserting release failed") - throw new Error(`Keygen - Upserting release returned no UUID: ${JSON.stringify(data)}`) - } - await this.uploadArtifact(releaseId, dataLength, requestProcessor) - return releaseId + + await this.uploadArtifact(data!.id, fileName, dataLength, requestProcessor) + + return data!.id }) } - private async uploadArtifact(releaseId: any, dataLength: number, requestProcessor: (request: ClientRequest, reject: (error: Error) => void) => void) { + private async uploadArtifact( + releaseId: any, + fileName: string, + dataLength: number, + requestProcessor: (request: ClientRequest, reject: (error: Error) => void) => void + ): Promise { + const { data, errors } = await this.createArtifact(releaseId, fileName, dataLength) + if (errors) { + throw new Error(`Keygen - Creating artifact returned errors: ${JSON.stringify(errors)}`) + } + + // Follow the redirect and upload directly to S3-equivalent storage provider + const url = new URL(data!.links.redirect) + const upload: RequestOptions = { + hostname: url.hostname, + path: url.pathname + url.search, + headers: { + "Content-Length": dataLength, + }, + } + + await httpExecutor.doApiRequest(configureRequestOptions(upload, null, "PUT"), this.context.cancellationToken, requestProcessor) + } + + private async createArtifact(releaseId: any, fileName: string, dataLength: number): Promise<{ data?: KeygenArtifact; errors?: KeygenError[] }> { const upload: RequestOptions = { hostname: this.hostname, - path: `${this.basePath}/${releaseId}/artifact`, + path: `${this.basePath}/artifacts`, headers: { + "Content-Type": "application/vnd.api+json", Accept: "application/vnd.api+json", - "Content-Length": dataLength, - "Keygen-Version": "1.0", + "Keygen-Version": "1.1", + Prefer: "no-redirect", + }, + } + + const data: RecursivePartial = { + type: "artifacts", + attributes: { + filename: fileName, + filetype: getCompleteExtname(fileName), + filesize: dataLength, + platform: this.info.platform, + }, + relationships: { + release: { + data: { + type: "releases", + id: releaseId, + }, + }, }, } - await httpExecutor.doApiRequest(configureRequestOptions(upload, this.auth, "PUT"), this.context.cancellationToken, requestProcessor) + + log.debug({ data: JSON.stringify(data) }, "Keygen create artifact") + + return parseJson(httpExecutor.request(configureRequestOptions(upload, this.auth, "POST"), this.context.cancellationToken, { data })) } - private async upsertRelease(fileName: string, dataLength: number): Promise<{ data: any; errors: any }> { + private async getOrCreateRelease(): Promise<{ data?: KeygenRelease; errors?: KeygenError[] }> { + try { + return await this.getRelease() + } catch (e) { + if (e.statusCode === 404) { + return this.createRelease() + } + + throw e + } + } + + private async getRelease(): Promise<{ data?: KeygenRelease; errors?: KeygenError[] }> { const req: RequestOptions = { hostname: this.hostname, - method: "PUT", - path: this.basePath, + path: `${this.basePath}/releases/${this.version}`, + headers: { + Accept: "application/vnd.api+json", + "Keygen-Version": "1.1", + }, + } + + return parseJson(httpExecutor.request(configureRequestOptions(req, this.auth, "GET"), this.context.cancellationToken, null)) + } + + private async createRelease(): Promise<{ data?: KeygenRelease; errors?: KeygenError[] }> { + const req: RequestOptions = { + hostname: this.hostname, + path: `${this.basePath}/releases`, headers: { "Content-Type": "application/vnd.api+json", Accept: "application/vnd.api+json", - "Keygen-Version": "1.0", + "Keygen-Version": "1.1", }, } - const data = { - data: { - type: "release", - attributes: { - filename: fileName, - filetype: getCompleteExtname(fileName), - filesize: dataLength, - version: this.version, - platform: this.info.platform, - channel: this.info.channel || "stable", - }, - relationships: { - product: { - data: { - type: "product", - id: this.info.product, - }, + + const data: RecursivePartial = { + type: "releases", + attributes: { + version: this.version, + channel: this.info.channel || "stable", + status: "PUBLISHED", + }, + relationships: { + product: { + data: { + type: "products", + id: this.info.product, }, }, }, } - log.debug({ data: JSON.stringify(data) }, "Keygen upsert release") - return parseJson(httpExecutor.request(configureRequestOptions(req, this.auth, "PUT"), this.context.cancellationToken, data)) + + log.debug({ data: JSON.stringify(data) }, "Keygen create release") + + return parseJson(httpExecutor.request(configureRequestOptions(req, this.auth, "POST"), this.context.cancellationToken, { data })) } async deleteRelease(releaseId: string): Promise { const req: RequestOptions = { hostname: this.hostname, - path: `${this.basePath}/${releaseId}`, + path: `${this.basePath}/releases/${releaseId}`, headers: { Accept: "application/vnd.api+json", - "Keygen-Version": "1.0", + "Keygen-Version": "1.1", }, } await httpExecutor.request(configureRequestOptions(req, this.auth, "DELETE"), this.context.cancellationToken) diff --git a/packages/electron-updater/src/providers/KeygenProvider.ts b/packages/electron-updater/src/providers/KeygenProvider.ts index 97b24961bd..a0d8b78d57 100644 --- a/packages/electron-updater/src/providers/KeygenProvider.ts +++ b/packages/electron-updater/src/providers/KeygenProvider.ts @@ -28,7 +28,7 @@ export class KeygenProvider extends Provider { channelUrl, { Accept: "application/vnd.api+json", - "Keygen-Version": "1.0", + "Keygen-Version": "1.1", }, cancellationToken ) diff --git a/test/src/ArtifactPublisherTest.ts b/test/src/ArtifactPublisherTest.ts index a99512708f..1e609048ff 100644 --- a/test/src/ArtifactPublisherTest.ts +++ b/test/src/ArtifactPublisherTest.ts @@ -132,8 +132,8 @@ test.ifEnv(process.env.KEYGEN_TOKEN)("Keygen upload", async () => { { provider: "keygen", // electron-builder-test - product: "43981278-96e7-47de-b8c2-98d59987206b", - account: "cdecda36-3ef0-483e-ad88-97e7970f3149", + product: process.env.KEYGEN_PRODUCT || "43981278-96e7-47de-b8c2-98d59987206b", + account: process.env.KEYGEN_ACCOUNT || "cdecda36-3ef0-483e-ad88-97e7970f3149", platform: Platform.MAC.name, } as KeygenOptions, versionNumber() diff --git a/test/src/updater/nsisUpdaterTest.ts b/test/src/updater/nsisUpdaterTest.ts index 03ac05989b..fd32798d8a 100644 --- a/test/src/updater/nsisUpdaterTest.ts +++ b/test/src/updater/nsisUpdaterTest.ts @@ -55,8 +55,8 @@ test.ifEnv(process.env.KEYGEN_TOKEN)("file url keygen", async () => { updater.addAuthHeader(`Bearer ${process.env.KEYGEN_TOKEN}`) updater.updateConfigPath = await writeUpdateConfig({ provider: "keygen", - product: "43981278-96e7-47de-b8c2-98d59987206b", - account: "cdecda36-3ef0-483e-ad88-97e7970f3149", + product: process.env.KEYGEN_PRODUCT || "43981278-96e7-47de-b8c2-98d59987206b", + account: process.env.KEYGEN_ACCOUNT || "cdecda36-3ef0-483e-ad88-97e7970f3149", }) await validateDownload(updater) })