Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: upgrade Keygen integration to v1.1 #6941

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
6 changes: 6 additions & 0 deletions .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.
218 changes: 177 additions & 41 deletions packages/app-builder-lib/src/publish/KeygenPublisher.ts
Expand Up @@ -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<T> = {
[P in keyof T]?: RecursivePartial<T[P]>
}

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"
Expand All @@ -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(
Expand All @@ -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<any> {
): Promise<string> {
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<void> {
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<KeygenArtifact> = {
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<KeygenRelease> = {
type: "releases",
attributes: {
version: this.version,
channel: this.info.channel || "stable",
status: "PUBLISHED",
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd prefer to start with a DRAFT status for the release, and publish only after all artifacts are uploaded, but I couldn't find a hook to accomplish that (i.e. letting the publisher perform an additional request once all artifacts are uploaded). LMK if one exists and I just happened to miss it.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmmm, I don't see any in the code for that functionality, but it totally seems like a valid reason to have one added :)
Seems like an onArtifactUploaded could be added here? Simply need to add a task (i.e. Promise) to the taskManager after the scheduleUpload

packager.artifactCreated(event => {
const publishConfiguration = event.publishConfig
if (publishConfiguration == null) {
this.taskManager.addTask(this.artifactCreatedWithoutExplicitPublishConfig(event))
} else if (this.isPublish) {
if (debug.enabled) {
debug(`artifactCreated (isPublish: ${this.isPublish}): ${safeStringifyJson(event, new Set(["packager"]))},\n publishConfig: ${safeStringifyJson(publishConfiguration)}`)
}
this.scheduleUpload(publishConfiguration, event, this.getAppInfo(event.packager))
}
})

From my quick look, an onAllArtifactsUploaded hook could probably be added here:

async awaitTasks(): Promise<void> {
await this.taskManager.awaitTasks()
const updateInfoFileTasks = this.updateFileWriteTask
if (this.cancellationToken.cancelled || updateInfoFileTasks.length === 0) {
return
}
await writeUpdateInfoFiles(updateInfoFileTasks, this.packager)
await this.taskManager.awaitTasks()
}
}

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If only adding onAllArtifactsUploaded, which is probably all we need for now, the best place might just be adding it here:

promise = publishManager.awaitTasks()

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was thinking about doing that in a separate follow-up PR, and then updating the Keygen publisher while I'm there. Is that cool? As-is, I think the current publisher works even if it does skip the draft stage.

Copy link
Collaborator

@mmaietta mmaietta Jun 17, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Works for me 😄
I think this is Ready for Review then? Merge button is disabled atm

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, it's ready for review but I wanted to wait until #6920 is out (which includes #6909) before we consider merging it, so I'm keeping the PR in draft mode. Detailed more of the reasoning there in the PR description.

LMK if you want me to move it out of a draft state. 👍

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Published 23.2.0 + electron-updater@5.0.6

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Awesome. Marked this as ready for review. 👍

},
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<void> {
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)
Expand Down
2 changes: 1 addition & 1 deletion packages/electron-updater/src/providers/KeygenProvider.ts
Expand Up @@ -28,7 +28,7 @@ export class KeygenProvider extends Provider<UpdateInfo> {
channelUrl,
{
Accept: "application/vnd.api+json",
"Keygen-Version": "1.0",
"Keygen-Version": "1.1",
},
cancellationToken
)
Expand Down
4 changes: 2 additions & 2 deletions test/src/ArtifactPublisherTest.ts
Expand Up @@ -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()
Expand Down
4 changes: 2 additions & 2 deletions test/src/updater/nsisUpdaterTest.ts
Expand Up @@ -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<KeygenOptions>({
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)
})
Expand Down