Skip to content
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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
165 changes: 164 additions & 1 deletion src/targets/__tests__/github.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { vi, type Mock, type MockInstance, type Mocked, type MockedFunction } from 'vitest';
import { isLatestRelease } from '../github';
import { isLatestRelease, GitHubTarget } from '../github';
import { NoneArtifactProvider } from '../../artifact_providers/none';
import { setGlobals } from '../../utils/helpers';

describe('isLatestRelease', () => {
it('works with missing latest release', () => {
Expand Down Expand Up @@ -62,3 +64,164 @@ describe('isLatestRelease', () => {
});
});
});

describe('GitHubTarget', () => {
const cleanEnv = { ...process.env };
let githubTarget: GitHubTarget;

beforeEach(() => {
process.env = {
...cleanEnv,
GITHUB_TOKEN: 'test github token',
};
setGlobals({ 'dry-run': false, 'log-level': 'Info', 'no-input': true });
vi.resetAllMocks();

githubTarget = new GitHubTarget(
{ name: 'github' },
new NoneArtifactProvider(),
{ owner: 'testOwner', repo: 'testRepo' }
);
});

afterEach(() => {
process.env = cleanEnv;
});

describe('publish', () => {
const mockDraftRelease = {
id: 123,
tag_name: 'v1.0.0',
upload_url: 'https://example.com/upload',
draft: true,
};

beforeEach(() => {
// Mock all the methods that publish depends on
githubTarget.getArtifactsForRevision = vi.fn().mockResolvedValue([]);
githubTarget.createDraftRelease = vi
.fn()
.mockResolvedValue(mockDraftRelease);
githubTarget.deleteRelease = vi.fn().mockResolvedValue(true);
githubTarget.publishRelease = vi.fn().mockResolvedValue(undefined);
githubTarget.github.repos.getLatestRelease = vi.fn().mockRejectedValue({
status: 404,
});
});

it('cleans up draft release when publishRelease fails', async () => {
const publishError = new Error('Publish failed');
githubTarget.publishRelease = vi.fn().mockRejectedValue(publishError);

await expect(
githubTarget.publish('1.0.0', 'abc123')
).rejects.toThrow('Publish failed');

expect(githubTarget.deleteRelease).toHaveBeenCalledWith(mockDraftRelease);
});

it('still throws original error if deleteRelease also fails', async () => {
const publishError = new Error('Publish failed');
const deleteError = new Error('Delete failed');

githubTarget.publishRelease = vi.fn().mockRejectedValue(publishError);
githubTarget.deleteRelease = vi.fn().mockRejectedValue(deleteError);

await expect(
githubTarget.publish('1.0.0', 'abc123')
).rejects.toThrow('Publish failed');

expect(githubTarget.deleteRelease).toHaveBeenCalledWith(mockDraftRelease);
});

it('does not delete release when publish succeeds', async () => {
await githubTarget.publish('1.0.0', 'abc123');

expect(githubTarget.deleteRelease).not.toHaveBeenCalled();
});
});

describe('deleteRelease', () => {
it('deletes a draft release', async () => {
const draftRelease = {
id: 123,
tag_name: 'v1.0.0',
upload_url: 'https://example.com/upload',
draft: true,
};

githubTarget.github.repos.deleteRelease = vi
.fn()
.mockResolvedValue({ status: 204 });

const result = await githubTarget.deleteRelease(draftRelease);

expect(result).toBe(true);
expect(githubTarget.github.repos.deleteRelease).toHaveBeenCalledWith({
release_id: 123,
owner: 'testOwner',
repo: 'testRepo',
changelog: 'CHANGELOG.md',
previewReleases: true,
tagPrefix: '',
tagOnly: false,
floatingTags: [],
});
});

it('refuses to delete a non-draft release', async () => {
const publishedRelease = {
id: 123,
tag_name: 'v1.0.0',
upload_url: 'https://example.com/upload',
draft: false,
};

githubTarget.github.repos.deleteRelease = vi
.fn()
.mockResolvedValue({ status: 204 });

const result = await githubTarget.deleteRelease(publishedRelease);

expect(result).toBe(false);
expect(githubTarget.github.repos.deleteRelease).not.toHaveBeenCalled();
});

it('allows deletion when draft status is undefined (backwards compatibility)', async () => {
const releaseWithoutDraftFlag = {
id: 123,
tag_name: 'v1.0.0',
upload_url: 'https://example.com/upload',
};

githubTarget.github.repos.deleteRelease = vi
.fn()
.mockResolvedValue({ status: 204 });

const result = await githubTarget.deleteRelease(releaseWithoutDraftFlag);

expect(result).toBe(true);
expect(githubTarget.github.repos.deleteRelease).toHaveBeenCalled();
});

it('does not delete in dry-run mode', async () => {
setGlobals({ 'dry-run': true, 'log-level': 'Info', 'no-input': true });

const draftRelease = {
id: 123,
tag_name: 'v1.0.0',
upload_url: 'https://example.com/upload',
draft: true,
};

githubTarget.github.repos.deleteRelease = vi
.fn()
.mockResolvedValue({ status: 204 });

const result = await githubTarget.deleteRelease(draftRelease);

expect(result).toBe(false);
expect(githubTarget.github.repos.deleteRelease).not.toHaveBeenCalled();
});
});
});
64 changes: 58 additions & 6 deletions src/targets/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ interface GitHubRelease {
tag_name: string;
/** Upload URL */
upload_url: string;
/** Whether this is a draft release */
draft?: boolean;
}

type ReposListAssetsForReleaseResponseItem = RestEndpointMethodTypes['repos']['listReleaseAssets']['response']['data'][0];
Expand Down Expand Up @@ -136,6 +138,7 @@ export class GitHubTarget extends BaseTarget {
id: 0,
tag_name: tag,
upload_url: '',
draft: true,
};
}

Expand Down Expand Up @@ -197,6 +200,40 @@ export class GitHubTarget extends BaseTarget {
);
}

/**
* Deletes the provided release if it is a draft
*
* Used to clean up orphaned draft releases when publish fails.
* Refuses to delete non-draft releases as a safety measure.
*
* @param release Release to delete
* @returns true if deleted, false if skipped (dry-run or not a draft)
*/
public async deleteRelease(release: GitHubRelease): Promise<boolean> {
this.logger.debug(`Deleting release: "${release.tag_name}"...`);

if (release.draft === false) {
this.logger.warn(
`Refusing to delete release "${release.tag_name}" because it is not a draft`
);
return false;
}

if (isDryRun()) {
this.logger.info(`[dry-run] Not deleting release "${release.tag_name}"`);
return false;
}

return (
(
await this.github.repos.deleteRelease({
release_id: release.id,
...this.githubConfig,
})
).status === 204
);
}

/**
* Fetches a list of all assets for the given release
*
Expand Down Expand Up @@ -532,13 +569,28 @@ export class GitHubTarget extends BaseTarget {
changelog
);

await Promise.all(
localArtifacts.map(({ path, mimeType }) =>
this.uploadAsset(draftRelease, path, mimeType)
)
);
try {
await Promise.all(
localArtifacts.map(({ path, mimeType }) =>
this.uploadAsset(draftRelease, path, mimeType)
)
);

await this.publishRelease(draftRelease, { makeLatest });
await this.publishRelease(draftRelease, { makeLatest });
} catch (error) {
// Clean up the orphaned draft release
try {
await this.deleteRelease(draftRelease);
this.logger.info(
`Deleted orphaned draft release: ${draftRelease.tag_name}`
);
} catch (deleteError) {
this.logger.warn(
`Failed to delete orphaned draft release: ${deleteError}`
);
}
throw error;
}

// Update floating tags (e.g., v2 for version 2.15.0)
await this.updateFloatingTags(version, revision);
Expand Down
21 changes: 18 additions & 3 deletions src/targets/upm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,9 +147,24 @@ export class UpmTarget extends BaseTarget {
targetRevision,
changes
);
await this.githubTarget.publishRelease(draftRelease, {
makeLatest: !isPrerelease,
});
try {
await this.githubTarget.publishRelease(draftRelease, {
makeLatest: !isPrerelease,
});
} catch (error) {
// Clean up the orphaned draft release
try {
await this.githubTarget.deleteRelease(draftRelease);
this.logger.info(
`Deleted orphaned draft release: ${draftRelease.tag_name}`
);
} catch (deleteError) {
this.logger.warn(
`Failed to delete orphaned draft release: ${deleteError}`
);
}
throw error;
}
}
},
true,
Expand Down
Loading