Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 105 additions & 0 deletions packages/publisher/github/spec/PublisherGitHub.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';

import { ForgeMakeResult } from '@electron-forge/shared-types';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import { PublisherGitHub } from '../src/PublisherGitHub';

import type { PublisherOptions } from '@electron-forge/publisher-base';

const { mockOctokit } = vi.hoisted(() => ({
mockOctokit: {
repos: {
listReleases: vi.fn(),
createRelease: vi.fn(),
uploadReleaseAsset: vi.fn(),
},
},
}));

vi.mock('../src/util/github', async (importOriginal) => {
const mod = await importOriginal<typeof import('../src/util/github')>();
class MockGitHub extends mod.default {
getGitHub() {
return mockOctokit as unknown as ReturnType<
InstanceType<typeof mod.default>['getGitHub']
>;
}
}
return { ...mod, default: MockGitHub };
});

describe('PublisherGitHub', () => {
let tmpDir: string;

const makeResultFor = async (
artifactName: string,
): Promise<ForgeMakeResult> => {
const artifactPath = path.resolve(tmpDir, artifactName);
await fs.writeFile(artifactPath, 'fake-artifact');
return {
artifacts: [artifactPath],
packageJSON: { version: '1.0.0' },
platform: 'darwin',
arch: 'x64',
};
};

const publishFor = async (
publisher: PublisherGitHub,
artifactName: string,
) => {
await publisher.publish({
dir: tmpDir,
makeResults: [await makeResultFor(artifactName)],
setStatusLine: vi.fn(),
} as unknown as PublisherOptions);
};

beforeEach(async () => {
// Ensure the spec does not depend on ambient credentials — CI runners
// have no GITHUB_TOKEN, so the publisher must rely on config.authToken
vi.stubEnv('GITHUB_TOKEN', '');
tmpDir = await fs.mkdtemp(
path.resolve(os.tmpdir(), 'forge-publisher-github-'),
);
// Simulate GitHub's eventually consistent "list releases" API by never
// returning the release created moments earlier in the same process
mockOctokit.repos.listReleases.mockResolvedValue({ data: [] });
mockOctokit.repos.createRelease.mockResolvedValue({
data: {
id: 123,
tag_name: 'v1.0.0',
upload_url: 'https://example.com/upload',
assets: [],
},
});
mockOctokit.repos.uploadReleaseAsset.mockImplementation(
async ({ name }: { name: string }) => ({ data: { name } }),
);
});

afterEach(async () => {
vi.unstubAllEnvs();
await fs.rm(tmpDir, { recursive: true, force: true });
});

it('does not create a duplicate release when publishing multiple dry runs for the same version', async () => {
const publisher = new PublisherGitHub({
repository: { owner: 'my-owner', name: 'my-repo' },
draft: true,
authToken: 'fake-token',
});

await publishFor(publisher, 'app-1.0.0-darwin.zip');
await publishFor(publisher, 'app-1.0.0-win32.zip');

expect(mockOctokit.repos.createRelease).toHaveBeenCalledOnce();
expect(mockOctokit.repos.uploadReleaseAsset).toHaveBeenCalledTimes(2);
for (const [args] of mockOctokit.repos.uploadReleaseAsset.mock.calls) {
expect(args.release_id).toEqual(123);
}
});
});
44 changes: 28 additions & 16 deletions packages/publisher/github/src/PublisherGitHub.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,13 @@ interface GitHubRelease {
upload_url: string;
}

type OctokitRelease = GetResponseDataTypeFromEndpointMethod<
Octokit['repos']['getRelease']
>;
type OctokitReleaseAsset = GetResponseDataTypeFromEndpointMethod<
Octokit['repos']['updateReleaseAsset']
>;

// Streams a file as the request body so that byte-level upload progress can be
// tracked. Octokit passes the body straight to `fetch`, which streams a
// ReadableStream body (with `duplex: 'half'`), invoking `onProgress` with the
Expand Down Expand Up @@ -58,6 +65,12 @@ function progressStream(
export default class PublisherGitHub extends PublisherBase<PublisherGitHubConfig> {
name = 'github';

// Releases we already found or created in this process, keyed by tag name.
// GitHub's "list releases" API is eventually consistent, so a release
// created moments ago (e.g. by a previous restored dry run) may be missing
// from the listing, which would cause a duplicate release to be created.
private knownReleases = new Map<string, OctokitRelease>();

async publish({
makeResults,
setStatusLine,
Expand Down Expand Up @@ -92,29 +105,25 @@ export default class PublisherGitHub extends PublisherBase<PublisherGitHubConfig
const github = new GitHub(config.authToken, true, config.octokitOptions);
github.getGitHub();

type OctokitRelease = GetResponseDataTypeFromEndpointMethod<
Octokit['repos']['getRelease']
>;
type OctokitReleaseAsset = GetResponseDataTypeFromEndpointMethod<
Octokit['repos']['updateReleaseAsset']
>;

for (const releaseVersion of Object.keys(perReleaseArtifacts)) {
let release: OctokitRelease | undefined;
const artifacts = perReleaseArtifacts[releaseVersion];
const releaseName = `${config.tagPrefix ?? 'v'}${releaseVersion}`;

setStatusLine(`Searching for target release: ${releaseName}`);
try {
release = (
await github.getGitHub().repos.listReleases({
owner: config.repository.owner,
repo: config.repository.name,
per_page: 100,
})
).data.find(
(testRelease: GitHubRelease) => testRelease.tag_name === releaseName,
);
release =
this.knownReleases.get(releaseName) ??
(
await github.getGitHub().repos.listReleases({
owner: config.repository.owner,
repo: config.repository.name,
per_page: 100,
})
).data.find(
(testRelease: GitHubRelease) =>
testRelease.tag_name === releaseName,
);
if (!release) {
throw new NoReleaseError(404);
}
Expand All @@ -138,6 +147,9 @@ export default class PublisherGitHub extends PublisherBase<PublisherGitHubConfig
}
}

// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
this.knownReleases.set(releaseName, release!);

const artifactPaths = artifacts.flatMap((artifact) => artifact.artifacts);
const artifactSizes = await Promise.all(
artifactPaths.map(async (p) => (await fs.stat(p)).size),
Expand Down