diff --git a/.changeset/silent-oranges-sniff.md b/.changeset/silent-oranges-sniff.md new file mode 100644 index 00000000000..cda2248bd8b --- /dev/null +++ b/.changeset/silent-oranges-sniff.md @@ -0,0 +1,8 @@ +--- +"app-builder-lib": patch +"builder-util-runtime": patch +"electron-publish": patch +"electron-updater": patch +--- + +Support gitlab publisher diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 0f3d16f8e46..40573557964 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -152,10 +152,11 @@ jobs: echo $TEST_RUNNER_IMAGE_TAG pnpm test-linux env: - TEST_FILES: nsisUpdaterTest,PublishManagerTest,differentialUpdateTest + TEST_FILES: nsisUpdaterTest,PublishManagerTest,differentialUpdateTest,GitlabPublisherTest,GitlabPublisher.integration.test KEYGEN_TOKEN: ${{ secrets.KEYGEN_TOKEN }} BITBUCKET_TOKEN: ${{ secrets.BITBUCKET_TOKEN }} GH_TOKEN: ${{ secrets.GH_TOKEN }} + GITLAB_TOKEN: ${{ secrets.GITLAB_TOKEN }} FORCE_COLOR: 1 TEST_RUNNER_IMAGE_TAG: electronuserland/builder:${{ env.TEST_IMAGE_NODE_MAJOR_VERSION }}-wine-mono diff --git a/packages/app-builder-lib/src/publish/PublishManager.ts b/packages/app-builder-lib/src/publish/PublishManager.ts index 5783d04a623..1d3640bbf6c 100644 --- a/packages/app-builder-lib/src/publish/PublishManager.ts +++ b/packages/app-builder-lib/src/publish/PublishManager.ts @@ -7,6 +7,7 @@ import { GithubOptions, githubTagPrefix, githubUrl, + GitlabOptions, KeygenOptions, Nullish, PublishConfiguration, @@ -18,6 +19,7 @@ import { BitbucketPublisher, getCiTag, GitHubPublisher, + GitlabPublisher, KeygenPublisher, PublishContext, Publisher, @@ -315,6 +317,9 @@ export async function createPublisher( case "github": return new GitHubPublisher(context, publishConfig as GithubOptions, version, options) + case "gitlab": + return new GitlabPublisher(context, publishConfig as GitlabOptions, version) + case "keygen": return new KeygenPublisher(context, publishConfig as KeygenOptions, version) @@ -336,6 +341,9 @@ async function requireProviderClass(provider: string, packager: Packager): Promi case "github": return GitHubPublisher + case "gitlab": + return GitlabPublisher + case "generic": return null @@ -442,6 +450,8 @@ async function resolvePublishConfigurations( let serviceName: PublishProvider | null = null if (!isEmptyOrSpaces(process.env.GH_TOKEN) || !isEmptyOrSpaces(process.env.GITHUB_TOKEN)) { serviceName = "github" + } else if (!isEmptyOrSpaces(process.env.GITLAB_TOKEN)) { + serviceName = "gitlab" } else if (!isEmptyOrSpaces(process.env.KEYGEN_TOKEN)) { serviceName = "keygen" } else if (!isEmptyOrSpaces(process.env.BITBUCKET_TOKEN)) { @@ -499,7 +509,7 @@ async function getResolvedPublishConfig( options: PublishConfiguration, arch: Arch | null, errorIfCannot: boolean -): Promise { +): Promise { options = { ...options } expandPublishConfig(options, platformPackager, packager, arch) diff --git a/packages/builder-util-runtime/src/index.ts b/packages/builder-util-runtime/src/index.ts index 4ca9e4bb743..4f1fd21686f 100644 --- a/packages/builder-util-runtime/src/index.ts +++ b/packages/builder-util-runtime/src/index.ts @@ -34,6 +34,8 @@ export { S3Options, SnapStoreOptions, SpacesOptions, + GitlabReleaseInfo, + GitlabReleaseAsset, } from "./publishOptions" export { retry } from "./retry" export { parseDn } from "./rfc2253Parser" diff --git a/packages/builder-util-runtime/src/publishOptions.ts b/packages/builder-util-runtime/src/publishOptions.ts index ccaa61bb1b5..c2a366fd158 100644 --- a/packages/builder-util-runtime/src/publishOptions.ts +++ b/packages/builder-util-runtime/src/publishOptions.ts @@ -174,26 +174,38 @@ export interface GitlabOptions extends PublishConfiguration { readonly provider: "gitlab" /** - * The GitLab project ID or namespace/project-name. + * The GitLab project ID or path (e.g., "12345678" or "namespace/project"). */ - readonly projectId: string | number + readonly projectId?: string | number | null /** - * The host (including the port if needed). + * The GitLab host (including the port if need). * @default gitlab.com */ readonly host?: string | null /** - * The access token to support auto-update from private GitLab repositories. Never specify it in the configuration files. Only for [setFeedURL](./auto-update.md#appupdatersetfeedurloptions). + * The access token to support auto-update from private GitLab repositories. Never specify it in the configuration files. */ readonly token?: string | null + /** + * Whether to use `v`-prefixed tag name. + * @default true + */ + readonly vPrefixedTagName?: boolean + /** * The channel. * @default latest */ readonly channel?: string | null + + /** + * Upload target method. Can be "project_upload" for GitLab project uploads or "generic_package" for GitLab generic packages. + * @default "project_upload" + */ + readonly uploadTarget?: "project_upload" | "generic_package" | null } /** @@ -447,6 +459,31 @@ export interface SpacesOptions extends BaseS3Options { readonly region: string } +export interface GitlabReleaseInfo { + name: string + tag_name: string + description: string + created_at: string + released_at: string + upcoming_release: boolean + assets: GitlabReleaseAsset +} + +export interface GitlabReleaseAsset { + count: number + sources: Array<{ + format: string + url: string + }> + links: Array<{ + id: number + name: string + url: string + direct_asset_url: string + link_type: string + }> +} + export function getS3LikeProviderBaseUrl(configuration: PublishConfiguration) { const provider = configuration.provider if (provider === "s3") { diff --git a/packages/electron-publish/src/gitlabPublisher.ts b/packages/electron-publish/src/gitlabPublisher.ts new file mode 100644 index 00000000000..9250fb0fb63 --- /dev/null +++ b/packages/electron-publish/src/gitlabPublisher.ts @@ -0,0 +1,373 @@ +import { Arch, Fields, httpExecutor, InvalidConfigurationError, isEmptyOrSpaces, isTokenCharValid, log } from "builder-util" +import { createReadStream } from "fs" +import { stat } from "fs/promises" +import { readFile } from "fs/promises" +import { configureRequestOptions, GitlabOptions, GitlabReleaseInfo, parseJson, HttpError } from "builder-util-runtime" +import { ClientRequest } from "http" +import { Lazy } from "lazy-val" +import * as mime from "mime" +import * as FormData from "form-data" +import { URL } from "url" +import { HttpPublisher } from "./httpPublisher" +import { PublishContext } from "./index" + +type RequestProcessor = (request: ClientRequest, reject: (error: Error) => void) => void + +export class GitlabPublisher extends HttpPublisher { + private readonly tag: string + readonly _release = new Lazy(() => (this.token === "__test__" ? Promise.resolve(null as any) : this.getOrCreateRelease())) + + private readonly token: string | null + private readonly host: string + private readonly baseApiPath: string + private readonly projectId: string + + readonly providerName = "gitlab" + + private releaseLogFields: Fields | null = null + + constructor( + context: PublishContext, + private readonly info: GitlabOptions, + private readonly version: string + ) { + super(context, true) + + let token = info.token || null + if (isEmptyOrSpaces(token)) { + token = process.env.GITLAB_TOKEN || null + if (isEmptyOrSpaces(token)) { + throw new InvalidConfigurationError(`GitLab Personal Access Token is not set, neither programmatically, nor using env "GITLAB_TOKEN"`) + } + + token = token.trim() + + if (!isTokenCharValid(token)) { + throw new InvalidConfigurationError(`GitLab Personal Access Token (${JSON.stringify(token)}) contains invalid characters, please check env "GITLAB_TOKEN"`) + } + } + + this.token = token + this.host = info.host || "gitlab.com" + this.projectId = this.resolveProjectId() + this.baseApiPath = `https://${this.host}/api/v4` + + if (version.startsWith("v")) { + throw new InvalidConfigurationError(`Version must not start with "v": ${version}`) + } + + // By default, we prefix the version with "v" + this.tag = info.vPrefixedTagName === false ? version : `v${version}` + } + + private async getOrCreateRelease(): Promise { + const logFields = { + tag: this.tag, + version: this.version, + } + + try { + const existingRelease = await this.getExistingRelease() + if (existingRelease) { + return existingRelease + } + + // Create new release if it doesn't exist + return this.createRelease() + } catch (error: any) { + const errorInfo = this.categorizeGitlabError(error) + log.error( + { + ...logFields, + error: error.message, + errorType: errorInfo.type, + statusCode: errorInfo.statusCode, + }, + "Failed to get or create GitLab release" + ) + throw error + } + } + + private async getExistingRelease(): Promise { + const url = this.buildProjectUrl("/releases") + const releases = await this.gitlabRequest(url) + + for (const release of releases) { + if (release.tag_name === this.tag) { + return release + } + } + + return null + } + + private async getDefaultBranch(): Promise { + try { + const url = this.buildProjectUrl() + const project = await this.gitlabRequest<{ default_branch: string }>(url) + return project.default_branch || "main" + } catch (error: any) { + log.warn({ error: error.message }, "Failed to get default branch, using 'main' as fallback") + return "main" + } + } + + private async createRelease(): Promise { + const releaseName = this.info.vPrefixedTagName === false ? this.version : `v${this.version}` + const branchName = await this.getDefaultBranch() + const releaseData = { + tag_name: this.tag, + name: releaseName, + description: `Release ${releaseName}`, + ref: branchName, + } + + log.debug( + { + tag: this.tag, + name: releaseName, + ref: branchName, + projectId: this.projectId, + }, + "creating GitLab release" + ) + + const url = this.buildProjectUrl("/releases") + return this.gitlabRequest(url, releaseData, "POST") + } + + protected async doUpload(fileName: string, arch: Arch, dataLength: number, requestProcessor: RequestProcessor, filePath: string): Promise { + const release = await this._release.value + if (release == null) { + log.warn({ file: fileName, ...this.releaseLogFields }, "skipped publishing") + return + } + + const logFields = { + file: fileName, + arch: Arch[arch], + size: dataLength, + uploadTarget: this.info.uploadTarget || "project_upload", + } + + try { + log.debug(logFields, "starting GitLab upload") + const assetPath = await this.uploadFileAndReturnAssetPath(fileName, dataLength, requestProcessor, filePath) + // Add the uploaded file as a release asset link + if (assetPath) { + await this.addReleaseAssetLink(fileName, assetPath) + log.info({ ...logFields, assetPath }, "GitLab upload completed successfully") + } else { + log.warn({ ...logFields }, "No asset URL found for file") + } + + return assetPath + } catch (e: any) { + const errorInfo = this.categorizeGitlabError(e) + log.error( + { + ...logFields, + error: e.message, + errorType: errorInfo.type, + statusCode: errorInfo.statusCode, + }, + "GitLab upload failed" + ) + throw e + } + } + + private async uploadFileAndReturnAssetPath(fileName: string, dataLength: number, requestProcessor: RequestProcessor, filePath: string) { + // Default to project_upload method + const uploadTarget = this.info.uploadTarget || "project_upload" + + let assetPath: string + if (uploadTarget === "generic_package") { + await this.uploadToGenericPackages(fileName, dataLength, requestProcessor) + // For generic packages, construct the download URL + const projectId = encodeURIComponent(this.projectId) + assetPath = `${this.baseApiPath}/projects/${projectId}/packages/generic/releases/${this.version}/${fileName}` + } else { + // Default to project_upload + const uploadResult = await this.uploadToProjectUpload(fileName, filePath) + // For project uploads, construct full URL from relative path + assetPath = `https://${this.host}${uploadResult.full_path}` + } + + return assetPath + } + + private async addReleaseAssetLink(fileName: string, assetUrl: string): Promise { + try { + const linkData = { + name: fileName, + url: assetUrl, + link_type: "other", + } + + const url = this.buildProjectUrl(`/releases/${this.tag}/assets/links`) + await this.gitlabRequest(url, linkData, "POST") + + log.debug({ fileName, assetUrl }, "Successfully linked asset to GitLab release") + } catch (e: any) { + log.warn({ fileName, assetUrl, error: e.message }, "Failed to link asset to GitLab release") + // Don't throw - the file was uploaded successfully, linking is optional + } + } + + private async uploadToProjectUpload(fileName: string, filePath: string): Promise { + const uploadUrl = `${this.baseApiPath}/projects/${encodeURIComponent(this.projectId)}/uploads` + const parsedUrl = new URL(uploadUrl) + + // Check file size to determine upload method + const stats = await stat(filePath) + const fileSize = stats.size + const STREAMING_THRESHOLD = 50 * 1024 * 1024 // 50MB + + const form = new FormData() + if (fileSize > STREAMING_THRESHOLD) { + // Use streaming for large files + log.debug({ fileName, fileSize }, "using streaming upload for large file") + const fileStream = createReadStream(filePath) + form.append("file", fileStream, fileName) + } else { + // Use buffer for small files + log.debug({ fileName, fileSize }, "using buffer upload for small file") + const fileContent = await readFile(filePath) + form.append("file", fileContent, fileName) + } + + const response = await httpExecutor.doApiRequest( + configureRequestOptions( + { + protocol: parsedUrl.protocol, + hostname: parsedUrl.hostname, + port: parsedUrl.port as any, + path: parsedUrl.pathname, + headers: { ...form.getHeaders(), ...this.setAuthHeaderForToken(this.token) }, + timeout: this.info.timeout || undefined, + }, + null, + "POST" + ), + this.context.cancellationToken, + (it: ClientRequest) => form.pipe(it) + ) + + // Parse the JSON response string + return JSON.parse(response) + } + + private async uploadToGenericPackages(fileName: string, dataLength: number, requestProcessor: RequestProcessor): Promise { + const uploadUrl = `${this.baseApiPath}/projects/${encodeURIComponent(this.projectId)}/packages/generic/releases/${this.version}/${fileName}` + const parsedUrl = new URL(uploadUrl) + + return httpExecutor.doApiRequest( + configureRequestOptions( + { + protocol: parsedUrl.protocol, + hostname: parsedUrl.hostname, + port: parsedUrl.port as any, + path: parsedUrl.pathname, + headers: { "Content-Length": dataLength, "Content-Type": mime.getType(fileName) || "application/octet-stream", ...this.setAuthHeaderForToken(this.token) }, + timeout: this.info.timeout || undefined, + }, + null, + "PUT" + ), + this.context.cancellationToken, + requestProcessor + ) + } + + private buildProjectUrl(path: string = ""): URL { + return new URL(`${this.baseApiPath}/projects/${encodeURIComponent(this.projectId)}${path}`) + } + + private resolveProjectId(): string { + if (this.info.projectId) { + return String(this.info.projectId) + } + + throw new InvalidConfigurationError("GitLab project ID is not specified, please set it in configuration.") + } + + private gitlabRequest(url: URL, data: { [name: string]: any } | null = null, method: "GET" | "POST" | "PUT" | "DELETE" = "GET"): Promise { + return parseJson( + httpExecutor.request( + configureRequestOptions( + { + port: url.port, + path: url.pathname, + protocol: url.protocol, + hostname: url.hostname, + headers: { "Content-Type": "application/json", ...this.setAuthHeaderForToken(this.token) }, + timeout: this.info.timeout || undefined, + }, + null, + method + ), + this.context.cancellationToken, + data + ) + ) + } + + private setAuthHeaderForToken(token: string | null): { [key: string]: string } { + const headers: { [key: string]: string } = {} + + if (token != null) { + // If the token starts with "Bearer", it is an OAuth application secret + // Note that the original gitlab token would not start with "Bearer" + // it might start with "gloas-", if so user needs to add "Bearer " prefix to the token + if (token.startsWith("Bearer")) { + headers.authorization = token + } else { + headers["PRIVATE-TOKEN"] = token + } + } + + return headers + } + + private categorizeGitlabError(error: any): { type: string; statusCode?: number } { + if (error instanceof HttpError) { + const statusCode = error.statusCode + + switch (statusCode) { + case 401: + return { type: "authentication", statusCode } + case 403: + return { type: "authorization", statusCode } + case 404: + return { type: "not_found", statusCode } + case 409: + return { type: "conflict", statusCode } + case 413: + return { type: "file_too_large", statusCode } + case 422: + return { type: "validation_error", statusCode } + case 429: + return { type: "rate_limit", statusCode } + case 500: + case 502: + case 503: + case 504: + return { type: "server_error", statusCode } + default: + return { type: "http_error", statusCode } + } + } + + if (error.code === "ECONNRESET" || error.code === "ENOTFOUND" || error.code === "ETIMEDOUT") { + return { type: "network_error" } + } + + return { type: "unknown_error" } + } + + toString() { + return `GitLab (project: ${this.projectId}, version: ${this.version})` + } +} diff --git a/packages/electron-publish/src/index.ts b/packages/electron-publish/src/index.ts index 64d1f148bc4..b2471778988 100644 --- a/packages/electron-publish/src/index.ts +++ b/packages/electron-publish/src/index.ts @@ -4,6 +4,7 @@ import { MultiProgress } from "./multiProgress" export { BitbucketPublisher } from "./bitbucketPublisher" export { GitHubPublisher } from "./gitHubPublisher" +export { GitlabPublisher } from "./gitlabPublisher" export { KeygenPublisher } from "./keygenPublisher" export { S3Publisher } from "./s3/s3Publisher" export { SpacesPublisher } from "./s3/spacesPublisher" diff --git a/packages/electron-updater/src/AppUpdater.ts b/packages/electron-updater/src/AppUpdater.ts index 7f29841551a..e69afe62d35 100644 --- a/packages/electron-updater/src/AppUpdater.ts +++ b/packages/electron-updater/src/AppUpdater.ts @@ -32,7 +32,6 @@ import type { TypedEmitter } from "tiny-typed-emitter" import Session = Electron.Session import type { AuthInfo } from "electron" import { gunzipSync, gzipSync } from "zlib" -import { blockmapFiles } from "./util" import { DifferentialDownloaderOptions } from "./differentialDownloader/DifferentialDownloader" import { GenericDifferentialDownloader } from "./differentialDownloader/GenericDifferentialDownloader" import { DOWNLOAD_PROGRESS, Logger, ResolvedUpdateFileInfo, UPDATE_DOWNLOADED, UpdateCheckResult, UpdateDownloadedEvent, UpdaterSignal } from "./types" @@ -809,7 +808,13 @@ export abstract class AppUpdater extends (EventEmitter as new () => TypedEmitter if (this._testOnlyOptions != null && !this._testOnlyOptions.isUseDifferentialDownload) { return true } - const blockmapFileUrls = blockmapFiles(fileInfo.url, this.app.version, downloadUpdateOptions.updateInfoAndProvider.info.version, this.previousBlockmapBaseUrlOverride) + const provider = downloadUpdateOptions.updateInfoAndProvider.provider + const blockmapFileUrls = await provider.getBlockMapFiles( + fileInfo.url, + this.app.version, + downloadUpdateOptions.updateInfoAndProvider.info.version, + this.previousBlockmapBaseUrlOverride + ) this._logger.info(`Download block maps (old: "${blockmapFileUrls[0]}", new: ${blockmapFileUrls[1]})`) const downloadBlockMap = async (url: URL): Promise => { diff --git a/packages/electron-updater/src/providers/GitLabProvider.ts b/packages/electron-updater/src/providers/GitLabProvider.ts index 1d573b77b3d..8b9a3bd0285 100644 --- a/packages/electron-updater/src/providers/GitLabProvider.ts +++ b/packages/electron-updater/src/providers/GitLabProvider.ts @@ -1,5 +1,7 @@ -import { CancellationToken, GitlabOptions, HttpError, newError, UpdateFileInfo, UpdateInfo } from "builder-util-runtime" +import { CancellationToken, GitlabOptions, HttpError, newError, UpdateFileInfo, UpdateInfo, GitlabReleaseInfo, GitlabReleaseAsset } from "builder-util-runtime" import { URL } from "url" +// @ts-ignore +import * as escapeRegExp from "lodash.escaperegexp" import { AppUpdater } from "../AppUpdater" import { ResolvedUpdateFileInfo } from "../types" import { getChannelFilename, newBaseUrl, newUrlFromBase } from "../util" @@ -10,33 +12,28 @@ interface GitlabUpdateInfo extends UpdateInfo { assets: Map // filename -> download URL mapping } -interface GitlabReleaseInfo { - name: string - tag_name: string - description: string - created_at: string - released_at: string - upcoming_release: boolean - assets: GitlabReleaseAsset -} - -interface GitlabReleaseAsset { - count: number - sources: Array<{ - format: string - url: string - }> - links: Array<{ - id: number - name: string - url: string - direct_asset_url: string - link_type: string - }> -} - export class GitLabProvider extends Provider { private readonly baseApiUrl: URL + // Cache the latest version info to avoid unnecessary HTTP requests + private cachedLatestVersion: GitlabUpdateInfo | null = null + + /** + * Normalizes filenames by replacing spaces and underscores with dashes. + * + * This is a workaround to handle filename formatting differences between tools: + * - electron-builder formats filenames like "test file.txt" as "test-file.txt" + * - GitLab may provide asset URLs using underscores, such as "test_file.txt" + * + * Because of this mismatch, we can't reliably extract the correct filename from + * the asset path without normalization. This function ensures consistent matching + * across different filename formats by converting all spaces and underscores to dashes. + * + * @param filename The filename to normalize + * @returns The normalized filename with spaces and underscores replaced by dashes + */ + private normalizeFilename(filename: string): string { + return filename.replace(/ |_/g, "-") + } constructor( private readonly options: GitlabOptions, @@ -68,14 +65,8 @@ export class GitLabProvider extends Provider { let latestRelease: GitlabReleaseInfo try { - const releaseResponse = await this.httpRequest( - latestReleaseUrl, - { - accept: "application/json", - "PRIVATE-TOKEN": this.options.token || "", - }, - cancellationToken - ) + const header = { "Content-Type": "application/json", ...this.setAuthHeaderForToken(this.options.token || null) } + const releaseResponse = await this.httpRequest(latestReleaseUrl, header, cancellationToken) if (!releaseResponse) { throw newError("No latest release found", "ERR_UPDATER_NO_PUBLISHED_VERSIONS") @@ -150,22 +141,168 @@ export class GitLabProvider extends Provider { // Create assets map from GitLab release assets const assetsMap = new Map() for (const asset of latestRelease.assets.links) { - assetsMap.set(asset.name.replace(/ /g, "-"), asset.direct_asset_url) + assetsMap.set(this.normalizeFilename(asset.name), asset.direct_asset_url) } - return { + const gitlabUpdateInfo = { tag: tag, assets: assetsMap, ...result, } + + // Cache the latest version info + this.cachedLatestVersion = gitlabUpdateInfo + + return gitlabUpdateInfo + } + + /** + * Utility function to convert GitlabReleaseAsset to Map + * Maps asset names to their download URLs + */ + private convertAssetsToMap(assets: GitlabReleaseAsset): Map { + const assetsMap = new Map() + for (const asset of assets.links) { + assetsMap.set(this.normalizeFilename(asset.name), asset.direct_asset_url) + } + return assetsMap + } + + /** + * Find blockmap file URL in assets map for a specific filename + */ + private findBlockMapInAssets(assets: Map, filename: string): URL | null { + const possibleBlockMapNames = [`${filename}.blockmap`, `${this.normalizeFilename(filename)}.blockmap`] + + for (const blockMapName of possibleBlockMapNames) { + const assetUrl = assets.get(blockMapName) + if (assetUrl) { + return new URL(assetUrl) + } + } + return null + } + + private async fetchReleaseInfoByVersion(version: string): Promise { + const cancellationToken = new CancellationToken() + + // Try v-prefixed version first, then fallback to plain version + const possibleReleaseIds = [`v${version}`, version] + + for (const releaseId of possibleReleaseIds) { + const releaseUrl = newUrlFromBase(`projects/${this.options.projectId}/releases/${encodeURIComponent(releaseId)}`, this.baseApiUrl) + + try { + const header = { "Content-Type": "application/json", ...this.setAuthHeaderForToken(this.options.token || null) } + const releaseResponse = await this.httpRequest(releaseUrl, header, cancellationToken) + + if (releaseResponse) { + const release: GitlabReleaseInfo = JSON.parse(releaseResponse) + return release + } + } catch (e: any) { + // If it's a 404 error, try the next release ID format + if (e instanceof HttpError && e.statusCode === 404) { + continue + } + // For other errors, throw immediately + throw newError(`Unable to find release ${releaseId} on GitLab (${releaseUrl}): ${e.stack || e.message}`, "ERR_UPDATER_RELEASE_NOT_FOUND") + } + } + + // If we get here, none of the release ID formats worked + throw newError(`Unable to find release with version ${version} (tried: ${possibleReleaseIds.join(", ")}) on GitLab`, "ERR_UPDATER_RELEASE_NOT_FOUND") + } + + private setAuthHeaderForToken(token: string | null): { [key: string]: string } { + const headers: { [key: string]: string } = {} + + if (token != null) { + // If the token starts with "Bearer", it is an OAuth application secret + // Note that the original gitlab token would not start with "Bearer" + // it might start with "gloas-", if so user needs to add "Bearer " prefix to the token + if (token.startsWith("Bearer")) { + headers.authorization = token + } else { + headers["PRIVATE-TOKEN"] = token + } + } + + return headers + } + + /** + * Get version info for blockmap files, using cache when possible + */ + private async getVersionInfoForBlockMap(version: string): Promise | null> { + // Check if we can use cached version info + if (this.cachedLatestVersion && this.cachedLatestVersion.version === version) { + return this.cachedLatestVersion.assets + } + + // Fetch version info if not cached or version doesn't match + const versionInfo = await this.fetchReleaseInfoByVersion(version) + if (versionInfo && versionInfo.assets) { + return this.convertAssetsToMap(versionInfo.assets) + } + + return null + } + + /** + * Find blockmap URLs from version assets + */ + private async findBlockMapUrlsFromAssets(oldVersion: string, newVersion: string, baseFilename: string): Promise<[URL | null, URL | null]> { + let newBlockMapUrl: URL | null = null + let oldBlockMapUrl: URL | null = null + + // Get new version assets + const newVersionAssets = await this.getVersionInfoForBlockMap(newVersion) + if (newVersionAssets) { + newBlockMapUrl = this.findBlockMapInAssets(newVersionAssets, baseFilename) + } + + // Get old version assets + const oldVersionAssets = await this.getVersionInfoForBlockMap(oldVersion) + if (oldVersionAssets) { + const oldFilename = baseFilename.replace(new RegExp(escapeRegExp(newVersion), "g"), oldVersion) + oldBlockMapUrl = this.findBlockMapInAssets(oldVersionAssets, oldFilename) + } + + return [oldBlockMapUrl, newBlockMapUrl] + } + + async getBlockMapFiles(baseUrl: URL, oldVersion: string, newVersion: string, oldBlockMapFileBaseUrl: string | null = null): Promise { + // If is `project_upload`, find blockmap files from corresponding gitLab assets + // Because each asset has an unique path that includes an identified hash code, + // e.g. https://gitlab.com/-/project/71361100/uploads/051f27a925eaf679f2ad688105362acc/latest.yml + if (this.options.uploadTarget === "project_upload") { + // Get the base filename from the URL to find corresponding blockmap files + const baseFilename = baseUrl.pathname.split("/").pop() || "" + + // Try to find blockmap files in GitLab assets + const [oldBlockMapUrl, newBlockMapUrl] = await this.findBlockMapUrlsFromAssets(oldVersion, newVersion, baseFilename) + + if (!newBlockMapUrl) { + throw newError(`Cannot find blockmap file for ${newVersion} in GitLab assets`, "ERR_UPDATER_BLOCKMAP_FILE_NOT_FOUND") + } + + if (!oldBlockMapUrl) { + throw newError(`Cannot find blockmap file for ${oldVersion} in GitLab assets`, "ERR_UPDATER_BLOCKMAP_FILE_NOT_FOUND") + } + + return [oldBlockMapUrl, newBlockMapUrl] + } else { + return super.getBlockMapFiles(baseUrl, oldVersion, newVersion, oldBlockMapFileBaseUrl) + } } resolveFiles(updateInfo: GitlabUpdateInfo): Array { return getFileList(updateInfo).map((fileInfo: UpdateFileInfo) => { - // GitLab assets may have spaces replaced with dashes + // Try both original and normalized filename formats const possibleNames = [ fileInfo.url, // Original filename - fileInfo.url.replace(/ /g, "-"), // Spaces replaced with dashes + this.normalizeFilename(fileInfo.url), // Normalized filename (spaces/underscores → dashes) ] const matchingAssetName = possibleNames.find(name => updateInfo.assets.has(name)) diff --git a/packages/electron-updater/src/providers/Provider.ts b/packages/electron-updater/src/providers/Provider.ts index bc4b9164c37..4d247325b33 100644 --- a/packages/electron-updater/src/providers/Provider.ts +++ b/packages/electron-updater/src/providers/Provider.ts @@ -5,6 +5,8 @@ import { URL } from "url" import { ElectronHttpExecutor } from "../electronHttpExecutor" import { ResolvedUpdateFileInfo } from "../types" import { newUrlFromBase } from "../util" +// @ts-ignore +import * as escapeRegExp from "lodash.escaperegexp" export type ProviderPlatform = "darwin" | "linux" | "win32" @@ -23,6 +25,18 @@ export abstract class Provider { this.executor = runtimeOptions.executor } + // By default, the blockmap file is in the same directory as the main file + // But some providers may have a different blockmap file, so we need to override this method + getBlockMapFiles(baseUrl: URL, oldVersion: string, newVersion: string, oldBlockMapFileBaseUrl: string | null = null): URL[] | Promise { + const newBlockMapUrl = newUrlFromBase(`${baseUrl.pathname}.blockmap`, baseUrl) + const oldBlockMapUrl = newUrlFromBase( + `${baseUrl.pathname.replace(new RegExp(escapeRegExp(newVersion), "g"), oldVersion)}.blockmap`, + oldBlockMapFileBaseUrl ? new URL(oldBlockMapFileBaseUrl) : baseUrl + ) + + return [oldBlockMapUrl, newBlockMapUrl] + } + get isUseMultipleRangeRequest(): boolean { return this.runtimeOptions.isUseMultipleRangeRequest !== false } diff --git a/packages/electron-updater/src/util.ts b/packages/electron-updater/src/util.ts index e9f0d652ff1..e1b48b0f890 100644 --- a/packages/electron-updater/src/util.ts +++ b/packages/electron-updater/src/util.ts @@ -1,7 +1,5 @@ // if baseUrl path doesn't ends with /, this path will be not prepended to passed pathname for new URL(input, base) import { URL } from "url" -// @ts-ignore -import * as escapeRegExp from "lodash.escaperegexp" /** @internal */ export function newBaseUrl(url: string): URL { @@ -29,13 +27,3 @@ export function newUrlFromBase(pathname: string, baseUrl: URL, addRandomQueryToA export function getChannelFilename(channel: string): string { return `${channel}.yml` } - -export function blockmapFiles(baseUrl: URL, oldVersion: string, newVersion: string, oldBlockMapFileBaseUrl: string | null = null): URL[] { - const newBlockMapUrl = newUrlFromBase(`${baseUrl.pathname}.blockmap`, baseUrl) - const oldBlockMapUrl = newUrlFromBase( - `${baseUrl.pathname.replace(new RegExp(escapeRegExp(newVersion), "g"), oldVersion)}.blockmap`, - oldBlockMapFileBaseUrl ? new URL(oldBlockMapFileBaseUrl) : baseUrl - ) - - return [oldBlockMapUrl, newBlockMapUrl] -} diff --git a/test/src/publisher/gitlab/GitlabPublisher.integration.test.ts b/test/src/publisher/gitlab/GitlabPublisher.integration.test.ts new file mode 100644 index 00000000000..f60f69487c9 --- /dev/null +++ b/test/src/publisher/gitlab/GitlabPublisher.integration.test.ts @@ -0,0 +1,162 @@ +import { Arch } from "builder-util" +import { CancellationToken } from "builder-util-runtime" +import { GitlabPublisher, PublishContext } from "electron-publish" +import { afterAll, beforeEach, describe, expect, test } from "vitest" +import { GitlabTestFixtures } from "./GitlabTestFixtures" +import { GitlabTestHelper } from "./GitlabTestHelper" + +/** + * GitLab Publisher Integration Tests + * + * Streamlined integration tests for GitlabPublisher core functionality. + * + * Prerequisites: + * - GITLAB_TOKEN environment variable with valid GitLab personal access token + * - GITLAB_TEST_PROJECT_ID for test project (default: 72170733) + * + * Coverage: + * - Authentication validation + * - Upload methods: project_upload and generic_package + * - Release creation and asset linking + * + * Features: + * - Automatic cleanup via afterAll hook + * - Minimal API calls for CI efficiency + */ + +function isAuthError(error: unknown): boolean { + const errorMessage = (error as Error)?.message || (error as any)?.description?.message || String(error) + return GitlabTestFixtures.ERROR_PATTERNS.auth.test(errorMessage) +} + +/** + * Cleans up test releases, preserving [v1.0.0, v1.1.0] baseline releases + */ +async function cleanupExistingReleases(): Promise { + const token = process.env.GITLAB_TOKEN + if (!token) { + // No GitLab token available for cleanup + return + } + + try { + const helper = new GitlabTestHelper() + const releases = await helper.getAllReleases() + + // Keep [v1.0.0, v1.1.0] baseline releases + const protectedReleases = ["v1.0.0", "1.0.0", "v1.1.0", "1.1.0"] + const releasesToDelete = releases.filter((release: any) => !protectedReleases.includes(release.tag_name)) + + if (releasesToDelete.length === 0) { + // No releases to cleanup + return + } + + // Delete releases, tags, and assets + const cleanupPromises = releasesToDelete.map(async (release: any) => { + try { + const versionId = release.tag_name + await helper.deleteUploadedAssets(versionId) + await helper.deleteReleaseAndTag(versionId) + // Cleaned up release: ${versionId} + } catch (_e: unknown) { + // Failed to cleanup release ${release.tag_name} + } + }) + + await Promise.allSettled(cleanupPromises) + // Cleanup completed. Deleted ${releasesToDelete.length} releases. + } catch (_e: unknown) { + // Failed to perform cleanup + } +} + +describe.sequential("GitLab Publisher - Integration Tests", () => { + let publishContext: PublishContext + let gitlabHelper: GitlabTestHelper + let testId: string + + beforeEach(() => { + testId = Date.now().toString() + publishContext = { + cancellationToken: new CancellationToken(), + progress: null, + } + gitlabHelper = new GitlabTestHelper() + }) + + afterAll(async () => { + await cleanupExistingReleases() + }, 120000) + + // Helper to create a publisher with unique version + function createPublisher(options: any = {}): GitlabPublisher { + const uniqueVersion = `${GitlabTestFixtures.VERSIONS.randomVersion()}.${testId}` + + return new GitlabPublisher( + publishContext, + GitlabTestFixtures.createOptions({ + ...options, + token: undefined, // Use environment token + }), + uniqueVersion + ) + } + + describe.sequential("Authentication", () => { + test("should reject invalid token", async () => { + const publisher = new GitlabPublisher( + publishContext, + GitlabTestFixtures.createOptions({ + token: GitlabTestFixtures.TOKENS.invalid, + }), + `${GitlabTestFixtures.VERSIONS.randomVersion()}.${testId}` + ) + + try { + await publisher.upload({ file: GitlabTestFixtures.ICON_PATH, arch: Arch.x64 }) + throw new Error("Expected authentication error") + } catch (error: unknown) { + expect(isAuthError(error), `Error: ${(error as Error).message}`).toBe(true) + } + }, 15000) + }) + + describe.sequential("File Upload", () => { + test.skipIf(!process.env.GITLAB_TOKEN)( + "should upload via project_upload, create release and link assets", + async () => { + const publisher = createPublisher() + await publisher.upload({ file: GitlabTestFixtures.ICON_PATH, arch: Arch.x64 }) + + const tag = (publisher as any).tag + const release = await gitlabHelper.getRelease(tag) + expect(release).toBeTruthy() + expect(GitlabTestFixtures.validateReleaseStructure(release)).toBe(true) + + const assets = release?.assets + expect(assets?.links?.length).toBeGreaterThan(0) + expect(GitlabTestFixtures.validateAssetLinkStructure(assets?.links[0], "project_upload")).toBe(true) + }, + 60000 + ) + + test.skipIf(!process.env.GITLAB_TOKEN)( + "should upload via generic_package, create release and link assets", + async () => { + const publisher = createPublisher({ uploadTarget: "generic_package" }) + await publisher.upload({ file: GitlabTestFixtures.ICON_PATH, arch: Arch.x64 }) + + const tag = (publisher as any).tag + const release = await gitlabHelper.getRelease(tag) + expect(release).toBeTruthy() + expect(GitlabTestFixtures.validateReleaseStructure(release)).toBe(true) + + const assets = release?.assets + expect(assets?.links?.length).toBeGreaterThan(0) + expect(GitlabTestFixtures.validateAssetLinkStructure(assets?.links[0], "generic_package")).toBe(true) + }, + 60000 + ) + }) +}) diff --git a/test/src/publisher/gitlab/GitlabPublisherTest.ts b/test/src/publisher/gitlab/GitlabPublisherTest.ts new file mode 100644 index 00000000000..8563fb86e1d --- /dev/null +++ b/test/src/publisher/gitlab/GitlabPublisherTest.ts @@ -0,0 +1,209 @@ +import { CancellationToken, GitlabOptions } from "builder-util-runtime" +import { GitlabPublisher, PublishContext } from "electron-publish" +import { beforeEach, describe, test, vi } from "vitest" +import { GitlabTestFixtures } from "./GitlabTestFixtures" + +// Mock the HTTP executor to avoid real network calls +vi.mock("builder-util", async () => { + const actual = await vi.importActual("builder-util") + return { + ...actual, + httpExecutor: { + doApiRequest: vi.fn(), + request: vi.fn(), + }, + } +}) + +describe("GitLab Publisher - Unit Tests", () => { + let publishContext: PublishContext + + beforeEach(() => { + publishContext = { + cancellationToken: new CancellationToken(), + progress: null, + } + vi.clearAllMocks() + }) + + describe("Configuration", () => { + describe("Authentication", () => { + test("should throw error for missing token", ({ expect }) => { + const env = GitlabTestFixtures.setupTestEnvironment() + + try { + delete process.env.GITLAB_TOKEN + + expect(() => { + new GitlabPublisher( + publishContext, + { + provider: "gitlab", + projectId: GitlabTestFixtures.PROJECTS.valid, + } as GitlabOptions, + GitlabTestFixtures.VERSIONS.valid + ) + }).toThrow(GitlabTestFixtures.ERROR_PATTERNS.missingToken) + } finally { + env.restore() + } + }) + }) + + describe("Project Configuration", () => { + test("should handle string project ID", ({ expect }) => { + const publisher = new GitlabPublisher( + publishContext, + GitlabTestFixtures.createOptions({ + projectId: GitlabTestFixtures.PROJECTS.stringFormat, + }), + GitlabTestFixtures.VERSIONS.valid + ) + + expect(publisher.toString()).toContain(GitlabTestFixtures.PROJECTS.stringFormat) + }) + + test("should handle numeric project ID", ({ expect }) => { + const publisher = new GitlabPublisher( + publishContext, + GitlabTestFixtures.createOptions({ + projectId: GitlabTestFixtures.PROJECTS.numericFormat, + }), + GitlabTestFixtures.VERSIONS.valid + ) + + expect(publisher.toString()).toContain(String(GitlabTestFixtures.PROJECTS.numericFormat)) + }) + }) + + describe("Version Handling", () => { + test("should reject version starting with 'v'", ({ expect }) => { + expect(() => { + new GitlabPublisher(publishContext, GitlabTestFixtures.createOptions(), GitlabTestFixtures.VERSIONS.invalidWithV) + }).toThrow(GitlabTestFixtures.ERROR_PATTERNS.invalidVersion) + }) + + test("should accept valid version", ({ expect }) => { + const publisher = new GitlabPublisher(publishContext, GitlabTestFixtures.createOptions(), GitlabTestFixtures.VERSIONS.valid) + + expect(publisher.toString()).toContain(GitlabTestFixtures.VERSIONS.valid) + }) + + test("should accept version with build metadata", ({ expect }) => { + const publisher = new GitlabPublisher(publishContext, GitlabTestFixtures.createOptions(), GitlabTestFixtures.VERSIONS.validWithBuild) + + expect(publisher.toString()).toContain(GitlabTestFixtures.VERSIONS.validWithBuild) + }) + + test("should handle vPrefixedTagName option", ({ expect }) => { + const publisherWithPrefix = new GitlabPublisher( + publishContext, + GitlabTestFixtures.createOptions({ + vPrefixedTagName: true, + }), + GitlabTestFixtures.VERSIONS.valid + ) + + const publisherWithoutPrefix = new GitlabPublisher( + publishContext, + GitlabTestFixtures.createOptions({ + vPrefixedTagName: false, + }), + GitlabTestFixtures.VERSIONS.valid + ) + + expect(publisherWithPrefix.toString()).toContain("GitLab") + expect(publisherWithoutPrefix.toString()).toContain("GitLab") + }) + }) + + describe("Host Configuration", () => { + test("should use default host", ({ expect }) => { + const publisher = new GitlabPublisher(publishContext, GitlabTestFixtures.createOptions(), GitlabTestFixtures.VERSIONS.valid) + + expect(publisher.toString()).toContain("GitLab") + }) + + test("should use custom host", ({ expect }) => { + const publisher = new GitlabPublisher( + publishContext, + GitlabTestFixtures.createOptions({ + host: GitlabTestFixtures.HOSTS.custom, + }), + GitlabTestFixtures.VERSIONS.valid + ) + + expect(publisher.toString()).toContain("GitLab") + }) + + test("should use enterprise host", ({ expect }) => { + const publisher = new GitlabPublisher( + publishContext, + GitlabTestFixtures.createOptions({ + host: GitlabTestFixtures.HOSTS.enterprise, + }), + GitlabTestFixtures.VERSIONS.valid + ) + + expect(publisher.toString()).toContain("GitLab") + }) + }) + }) + + describe("Publisher String Representation", () => { + test("should return meaningful string representation", ({ expect }) => { + const publisher = new GitlabPublisher( + publishContext, + GitlabTestFixtures.createOptions({ + projectId: "test-project", + }), + "1.2.3" + ) + + const str = publisher.toString() + expect(str).toContain("GitLab") + expect(str).toContain("test-project") + expect(str).toContain("1.2.3") + }) + }) + + describe("Upload Target Configuration", () => { + test("should default to project_upload method", ({ expect }) => { + const publisher = new GitlabPublisher(publishContext, GitlabTestFixtures.createOptions(), GitlabTestFixtures.VERSIONS.valid) + + expect(publisher.toString()).toContain("GitLab") + }) + + test("should accept generic_package upload target", ({ expect }) => { + const publisher = new GitlabPublisher( + publishContext, + GitlabTestFixtures.createOptions({ + uploadTarget: "generic_package", + }), + GitlabTestFixtures.VERSIONS.valid + ) + + expect(publisher.toString()).toContain("GitLab") + }) + + test("should accept custom timeout", ({ expect }) => { + const publisher = new GitlabPublisher( + publishContext, + GitlabTestFixtures.createOptions({ + timeout: 60000, + }), + GitlabTestFixtures.VERSIONS.valid + ) + + expect(publisher.toString()).toContain("GitLab") + }) + }) + + describe("Provider Name", () => { + test("should return correct provider name", ({ expect }) => { + const publisher = new GitlabPublisher(publishContext, GitlabTestFixtures.createOptions(), GitlabTestFixtures.VERSIONS.valid) + + expect(publisher.providerName).toBe("gitlab") + }) + }) +}) diff --git a/test/src/publisher/gitlab/GitlabTestFixtures.ts b/test/src/publisher/gitlab/GitlabTestFixtures.ts new file mode 100644 index 00000000000..e34bcebff6d --- /dev/null +++ b/test/src/publisher/gitlab/GitlabTestFixtures.ts @@ -0,0 +1,137 @@ +import * as path from "path" +import { GitlabOptions } from "builder-util-runtime" + +export class GitlabTestFixtures { + // Test file paths + static readonly ICON_PATH = path.join(__dirname, "..", "..", "..", "fixtures", "test-app", "build", "icon.icns") + static readonly ICO_PATH = path.join(__dirname, "..", "..", "..", "fixtures", "test-app", "build", "icon.ico") + + // Test versions + static readonly VERSIONS = { + valid: "1.0.0", + validWithBuild: "1.0.0-beta.1", + invalidWithV: "v1.0.0", + randomVersion: () => GitlabTestFixtures.generateRandomVersion(), + } as const + + // Project configurations + static readonly PROJECTS = { + valid: "72170733", + nonExistent: "99999999", + stringFormat: "namespace/project-name", + numericFormat: 12345678, + } as const + + // Host configurations + static readonly HOSTS = { + gitlab: "gitlab.com", + custom: "gitlab.example.com", + enterprise: "git.company.com", + } as const + + // Token configurations + static readonly TOKENS = { + test: "__test__", + invalid: "invalid-token", + malformed: "glpat-invalid!@#$%", + } as const + + // Error patterns for assertions + static readonly ERROR_PATTERNS = { + auth: /(Unauthorized|401|invalid token|Bad credentials|403|Forbidden)/i, + notFound: /(404|not found|doesn't exist)/i, + rateLimit: /rate limit exceeded/i, + missingToken: /GitLab Personal Access Token is not set/i, + missingProject: /GitLab project ID or path is not specified/i, + invalidVersion: /Version must not start with "v"/i, + } as const + + // Default configurations + static readonly DEFAULT_OPTIONS: GitlabOptions = { + provider: "gitlab", + projectId: GitlabTestFixtures.PROJECTS.valid, + host: GitlabTestFixtures.HOSTS.gitlab, + token: GitlabTestFixtures.TOKENS.test, + } + + static readonly DEFAULT_PUBLISH_OPTIONS = { + publish: "always" as const, + } + + // Helper methods + static generateRandomVersion(): string { + const randomInt = (min: number, max: number) => Math.floor(Math.random() * (max - min + 1)) + min + return `${randomInt(0, 99)}.${randomInt(0, 99)}.${randomInt(0, 99)}` + } + + static createOptions(overrides: Partial = {}): GitlabOptions { + return { ...GitlabTestFixtures.DEFAULT_OPTIONS, ...overrides } + } + + static createTestFiles(): { name: string; path: string }[] { + return [ + { name: "icon.icns", path: GitlabTestFixtures.ICON_PATH }, + { name: "icon.ico", path: GitlabTestFixtures.ICO_PATH }, + ] + } + + // Environment setup helpers + static setupTestEnvironment(): { + original: Record + restore: () => void + } { + const original = { + GITLAB_TOKEN: process.env.GITLAB_TOKEN, + CI_PROJECT_ID: process.env.CI_PROJECT_ID, + CI_PROJECT_PATH: process.env.CI_PROJECT_PATH, + } + + return { + original, + restore: () => { + Object.entries(original).forEach(([key, value]) => { + if (value !== undefined) { + process.env[key] = value + } else { + delete process.env[key] + } + }) + }, + } + } + + // Assertion helpers for test validation + static validateReleaseStructure(release: unknown): boolean { + const r = release as any + return typeof r?.tag_name === "string" && typeof r?.name === "string" && Array.isArray(r?.assets?.links) + } + + static validateAssetLinkStructure(link: unknown, assetType: "project_upload" | "generic_package"): boolean { + const l = link as any + + // GitLab upload URL pattern: https://gitlab.com/-/project/{projectId}/uploads/{hash}/{filename} + const gitlabUploadUrlPattern = /^https:\/\/gitlab\.com\/-\/project\/\d+\/uploads\/[a-f0-9]{32}\/.+$/ + + // GitLab generic package URL pattern: https://gitlab.com/api/v4/projects/{projectId}/packages/generic/{packageName}/{packageVersion}/{filename} + const gitlabGenericPackageUrlPattern = /^https:\/\/gitlab\.com\/api\/v4\/projects\/\d+\/packages\/generic\/.+\/.+\/.+$/ + + const isValidUrl = (url: string) => { + if (assetType === "project_upload") { + return gitlabUploadUrlPattern.test(url) + } else if (assetType === "generic_package") { + return gitlabGenericPackageUrlPattern.test(url) + } + return false + } + + return ( + (typeof l?.id === "string" || typeof l?.id === "number") && + typeof l?.name === "string" && + typeof l?.url === "string" && + isValidUrl(l?.url) && + typeof l?.direct_asset_url === "string" && + isValidUrl(l?.direct_asset_url) && + typeof l?.link_type === "string" + ) + } +} diff --git a/test/src/publisher/gitlab/GitlabTestHelper.ts b/test/src/publisher/gitlab/GitlabTestHelper.ts new file mode 100644 index 00000000000..35acf0040a8 --- /dev/null +++ b/test/src/publisher/gitlab/GitlabTestHelper.ts @@ -0,0 +1,175 @@ +import { httpExecutor, log } from "builder-util" +import { GitlabReleaseInfo, HttpError } from "builder-util-runtime" + +/** + * Helper class for GitLab test operations + * + * Provides methods for interacting with GitLab API during testing, + * including release management and cleanup operations. + */ +export class GitlabTestHelper { + private readonly token: string + private readonly host: string + private readonly projectId: string + + constructor({ + // gitlab repo for this project is `https://gitlab.com/daihere1993/gitlab-electron-updater-test-2` + projectId = "72170733", + host = "gitlab.com", + }: { + projectId?: string + host?: string + } = {}) { + this.token = process.env.GITLAB_TOKEN || "" + this.host = host + this.projectId = String(projectId) + } + + /** + * Make authenticated request to GitLab API + */ + private async gitlabRequest(path: string, data?: any, method: string = "GET"): Promise { + const requestOptions = { + hostname: this.host, + path: `/api/v4${path}`, + method: method, + headers: { + "Private-Token": this.token, + accept: "application/json", + }, + } + + try { + const response = await httpExecutor.request(requestOptions, undefined, data) + return response ? JSON.parse(response) : null + } catch (e: unknown) { + if (e instanceof HttpError && e.statusCode === 404) { + return null + } + throw e + } + } + + /** + * Get release information by tag name + */ + async getRelease(releaseId: string): Promise { + try { + return await this.gitlabRequest(`/projects/${encodeURIComponent(this.projectId)}/releases/${releaseId}`) + } catch (e: unknown) { + if (e instanceof HttpError && e.statusCode === 404) { + return null + } + throw e + } + } + + /** + * Delete GitLab release by tag name + */ + async deleteRelease(releaseId: string): Promise { + const release = await this.getRelease(releaseId) + if (release == null) { + log.warn({ releaseId, reason: "doesn't exist" }, "cannot delete release") + return + } + + try { + await this.gitlabRequest(`/projects/${encodeURIComponent(this.projectId)}/releases/${releaseId}`, null, "DELETE") + } catch (e: unknown) { + if (e instanceof HttpError && e.statusCode === 404) { + log.warn({ releaseId, reason: "doesn't exist" }, "cannot delete release") + return + } + throw e + } + } + + /** + * Delete GitLab release and corresponding git tag + */ + async deleteReleaseAndTag(releaseId: string): Promise { + const release = await this.getRelease(releaseId) + if (release == null) { + log.warn({ releaseId, reason: "doesn't exist" }, "cannot delete release") + return + } + + try { + // First delete the release + await this.deleteRelease(releaseId) + log.debug({ releaseId }, "Deleted GitLab release") + } catch (e: unknown) { + log.warn({ releaseId, error: (e as Error).message }, "Failed to delete GitLab release") + } + + try { + // Then delete the git tag + await this.gitlabRequest(`/projects/${encodeURIComponent(this.projectId)}/repository/tags/${encodeURIComponent(releaseId)}`, null, "DELETE") + log.debug({ releaseId }, "Deleted GitLab tag") + } catch (e: unknown) { + if (e instanceof HttpError && e.statusCode === 404) { + log.warn({ releaseId, reason: "doesn't exist" }, "cannot delete git tag") + return + } + log.warn({ releaseId, error: (e as Error).message }, "Failed to delete GitLab tag") + } + } + + /** + * Delete uploaded assets (generic packages only) + * + * Note: Project uploads are automatically deleted by GitLab when the release is deleted. + */ + async deleteUploadedAssets(releaseId: string): Promise { + try { + // Only need to delete generic packages - project uploads are auto-deleted with releases + await this.deleteGenericPackages(releaseId) + } catch (e: unknown) { + log.warn({ releaseId, error: (e as Error).message }, "Failed to cleanup uploaded assets") + } + } + + /** + * Delete generic packages matching version from Package Registry + */ + async deleteGenericPackages(version: string): Promise { + try { + // Get all packages for the "releases" package name + const packages = await this.gitlabRequest(`/projects/${encodeURIComponent(this.projectId)}/packages?package_name=releases`) + + if (!packages || packages.length === 0) { + return + } + + // Find packages that match our version + const matchingPackages = packages.filter(pkg => pkg.name === "releases" && pkg.version === version) + + // Delete matching packages + const deletePromises = matchingPackages.map(async (pkg: any) => { + try { + await this.gitlabRequest(`/projects/${encodeURIComponent(this.projectId)}/packages/${pkg.id}`, null, "DELETE") + log.debug({ packageId: pkg.id, version: pkg.version }, "Deleted GitLab generic package") + } catch (e: unknown) { + log.warn({ packageId: pkg.id, version: pkg.version, error: (e as Error).message }, "Failed to delete GitLab generic package") + } + }) + + await Promise.allSettled(deletePromises) + } catch (e: unknown) { + log.warn({ version, error: (e as Error).message }, "Failed to cleanup generic packages") + } + } + + /** + * Get all releases from GitLab project + */ + async getAllReleases(): Promise { + try { + const releases = await this.gitlabRequest(`/projects/${encodeURIComponent(this.projectId)}/releases`) + return releases || [] + } catch (e: unknown) { + throw new Error(`Failed to get all releases: ${(e as Error).message}`) + } + } +} diff --git a/test/src/updater/nsisUpdaterTest.ts b/test/src/updater/nsisUpdaterTest.ts index 7e5bb7d848f..3e60a63d404 100644 --- a/test/src/updater/nsisUpdaterTest.ts +++ b/test/src/updater/nsisUpdaterTest.ts @@ -8,6 +8,8 @@ import { assertThat } from "../helpers/fileAssert" import { removeUnstableProperties } from "../helpers/packTester" import { createNsisUpdater, trackEvents, validateDownload, writeUpdateConfig } from "../helpers/updaterTestUtil" import { ExpectStatic } from "vitest" +import { GitLabProvider } from "electron-updater/src/providers/GitLabProvider" +import { GitHubProvider } from "electron-updater/src/providers/GitHubProvider" const config = { retry: 3 } @@ -59,6 +61,32 @@ test("github allowPrerelease=false", config, async ({ expect }) => { expect(removeUnstableProperties(updateCheckResult?.updateInfo)).toMatchSnapshot() }) +test("github blockmap files - should get blockmap files", config, async ({ expect }) => { + const updater = await createNsisUpdater("1.0.0") + updater.updateConfigPath = await writeUpdateConfig({ + provider: "github", + owner: "develar", + repo: "__test_nsis_release", + }) + + await updater.checkForUpdates() + + const provider = (updater as any)?.updateInfoAndProvider?.provider as GitHubProvider + if (provider) { + const oldVersion = "1.1.9-2+ed8ccd" + const newVersion = "1.1.9-3+be4a1f" + const baseUrlString = `https://github.com/artifacts/master/raw/electron%20Setup%20${newVersion}.exe` + const baseUrl = new URL(baseUrlString) + + const blockMapUrls = await provider.getBlockMapFiles(baseUrl, oldVersion, newVersion) + const oldBlockMapUrl = blockMapUrls[0] + const newBlockMapUrl = blockMapUrls[1] + + expect(oldBlockMapUrl.href).toBe("https://github.com/artifacts/master/raw/electron%20Setup%201.1.9-2+ed8ccd.exe.blockmap") + expect(newBlockMapUrl.href).toBe("https://github.com/artifacts/master/raw/electron%20Setup%201.1.9-3+be4a1f.exe.blockmap") + } +}) + test("file url generic", config, async ({ expect }) => { const updater = await createNsisUpdater() updater.updateConfigPath = await writeUpdateConfig({ @@ -146,6 +174,58 @@ test("gitlab - manual download", config, async ({ expect }) => { await assertThat(expect, path.join((await updater.downloadUpdate())[0])).isFile() }) +test("gitlab blockmap files - should get blockmap files from project_upload", config, async ({ expect }) => { + const updater = await createNsisUpdater("1.0.0") + updater.updateConfigPath = await writeUpdateConfig({ + provider: "gitlab", + projectId: 71361100, + uploadTarget: "project_upload", + }) + + await updater.checkForUpdates() + + const provider = (updater as any)?.updateInfoAndProvider?.provider as GitLabProvider + if (provider) { + const baseUrl = new URL("https://gitlab.com/gitlab-electron-updater-test_Setup_1.1.0.exe") + const blockMapUrls = await provider.getBlockMapFiles(baseUrl, "1.0.0", "1.1.0") + + expect(blockMapUrls).toHaveLength(2) + + const oldBlockMapUrl = blockMapUrls[0] + const newBlockMapUrl = blockMapUrls[1] + expect(oldBlockMapUrl).toBeInstanceOf(URL) + expect(newBlockMapUrl).toBeInstanceOf(URL) + expect(oldBlockMapUrl.href).toContain("gitlab-electron-updater-test_Setup_1.0.0.exe.blockmap") + expect(newBlockMapUrl.href).toContain("gitlab-electron-updater-test_Setup_1.1.0.exe.blockmap") + } +}) + +test("gitlab blockmap files - should get blockmap files from generic_package", config, async ({ expect }) => { + const updater = await createNsisUpdater("1.0.0") + updater.updateConfigPath = await writeUpdateConfig({ + provider: "gitlab", + projectId: 71361100, + uploadTarget: "generic_package", + }) + + await updater.checkForUpdates() + + const provider = (updater as any)?.updateInfoAndProvider?.provider as GitLabProvider + if (provider) { + const baseUrl = new URL("https://gitlab.com/gitlab-electron-updater-test_Setup_1.1.0.exe") + const blockMapUrls = await provider.getBlockMapFiles(baseUrl, "1.0.0", "1.1.0") + + expect(blockMapUrls).toHaveLength(2) + + const oldBlockMapUrl = blockMapUrls[0] + const newBlockMapUrl = blockMapUrls[1] + expect(oldBlockMapUrl).toBeInstanceOf(URL) + expect(newBlockMapUrl).toBeInstanceOf(URL) + expect(oldBlockMapUrl.href).toBe("https://gitlab.com/gitlab-electron-updater-test_Setup_1.0.0.exe.blockmap") + expect(newBlockMapUrl.href).toBe("https://gitlab.com/gitlab-electron-updater-test_Setup_1.1.0.exe.blockmap") + } +}) + test.skip("DigitalOcean Spaces", config, async ({ expect }) => { const updater = await createNsisUpdater() updater.updateConfigPath = await writeUpdateConfig({ diff --git a/test/src/urlUtilTest.ts b/test/src/urlUtilTest.ts index 76986e1f01d..93635d211df 100644 --- a/test/src/urlUtilTest.ts +++ b/test/src/urlUtilTest.ts @@ -1,4 +1,4 @@ -import { blockmapFiles, newUrlFromBase } from "electron-updater/out/util" +import { newUrlFromBase } from "electron-updater/out/util" import { URL } from "url" test("newUrlFromBase", ({ expect }) => { @@ -12,15 +12,3 @@ test("add no cache", ({ expect }) => { const newBlockMapUrl = newUrlFromBase("latest.yml", baseUrl, true) expect(newBlockMapUrl.href).toBe("https://gitlab.com/artifacts/master/raw/latest.yml?job=build_electron_win") }) - -test("create blockmap urls", ({ expect }) => { - const oldVersion = "1.1.9-2+ed8ccd" - const newVersion = "1.1.9-3+be4a1f" - const baseUrlString = `https://gitlab.com/artifacts/master/raw/electron%20Setup%20${newVersion}.exe` - const baseUrl = new URL(baseUrlString) - - const blockMapUrls = blockmapFiles(baseUrl, oldVersion, newVersion) - - expect(blockMapUrls[0].href).toBe("https://gitlab.com/artifacts/master/raw/electron%20Setup%201.1.9-2+ed8ccd.exe.blockmap") - expect(blockMapUrls[1].href).toBe("https://gitlab.com/artifacts/master/raw/electron%20Setup%201.1.9-3+be4a1f.exe.blockmap") -}) diff --git a/vite.config.ts b/vite.config.ts index 99cdf25dbcd..23c0458d071 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -2,7 +2,7 @@ import { defineConfig } from "vitest/config" import fs from "fs" export default () => { - const testRegex = process.env.TEST_FILES?.split(",") ?? ["*Test"] + const testRegex = process.env.TEST_FILES?.split(",") ?? ["*Test", "*test"] const includeRegex = `(${testRegex.join("|")})` console.log("TEST_FILES pattern", includeRegex)