mirror of
https://gh-proxy.org/https://github.com/softprops/action-gh-release.git
synced 2026-07-16 07:23:00 +08:00
Compare commits
2 Commits
f345337888
...
7e13ed4ac5
| Author | SHA1 | Date | |
|---|---|---|---|
| 7e13ed4ac5 | |||
| e6c70a53cf |
+265
-2
@@ -322,6 +322,107 @@ describe('github', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('GitHubReleaser', () => {
|
describe('GitHubReleaser', () => {
|
||||||
|
it('uses GitHub standard asset deletion without adding the release ID', async () => {
|
||||||
|
const deleteReleaseAsset = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const request = vi.fn();
|
||||||
|
const releaser = new GitHubReleaser({
|
||||||
|
rest: { repos: { deleteReleaseAsset } },
|
||||||
|
request,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
await releaser.deleteReleaseAsset({
|
||||||
|
owner: 'owner',
|
||||||
|
repo: 'repo',
|
||||||
|
release_id: 42,
|
||||||
|
asset_id: 99,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(deleteReleaseAsset).toHaveBeenCalledOnce();
|
||||||
|
expect(deleteReleaseAsset).toHaveBeenCalledWith({
|
||||||
|
owner: 'owner',
|
||||||
|
repo: 'repo',
|
||||||
|
asset_id: 99,
|
||||||
|
});
|
||||||
|
expect(request).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to release-scoped asset deletion after a standard 404', async () => {
|
||||||
|
const deleteReleaseAsset = vi.fn().mockRejectedValue({
|
||||||
|
status: 404,
|
||||||
|
message: 'standard route not found',
|
||||||
|
});
|
||||||
|
const request = vi.fn().mockResolvedValue({ status: 204 });
|
||||||
|
const releaser = new GitHubReleaser({
|
||||||
|
rest: { repos: { deleteReleaseAsset } },
|
||||||
|
request,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
await releaser.deleteReleaseAsset({
|
||||||
|
owner: 'owner',
|
||||||
|
repo: 'repo',
|
||||||
|
release_id: 42,
|
||||||
|
asset_id: 99,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(request).toHaveBeenCalledOnce();
|
||||||
|
expect(request).toHaveBeenCalledWith(
|
||||||
|
'DELETE /repos/{owner}/{repo}/releases/{release_id}/assets/{asset_id}',
|
||||||
|
{
|
||||||
|
owner: 'owner',
|
||||||
|
repo: 'repo',
|
||||||
|
release_id: 42,
|
||||||
|
asset_id: 99,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not mask non-404 asset deletion failures', async () => {
|
||||||
|
const deletionError = { status: 403, message: 'forbidden' };
|
||||||
|
const request = vi.fn();
|
||||||
|
const releaser = new GitHubReleaser({
|
||||||
|
rest: {
|
||||||
|
repos: { deleteReleaseAsset: vi.fn().mockRejectedValue(deletionError) },
|
||||||
|
},
|
||||||
|
request,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
releaser.deleteReleaseAsset({
|
||||||
|
owner: 'owner',
|
||||||
|
repo: 'repo',
|
||||||
|
release_id: 42,
|
||||||
|
asset_id: 99,
|
||||||
|
}),
|
||||||
|
).rejects.toBe(deletionError);
|
||||||
|
expect(request).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports both failures when release-scoped deletion also fails', async () => {
|
||||||
|
const releaser = new GitHubReleaser({
|
||||||
|
rest: {
|
||||||
|
repos: {
|
||||||
|
deleteReleaseAsset: vi
|
||||||
|
.fn()
|
||||||
|
.mockRejectedValue({ status: 404, message: 'standard route not found' }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
request: vi
|
||||||
|
.fn()
|
||||||
|
.mockRejectedValue({ status: 404, message: 'release-scoped route not found' }),
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
releaser.deleteReleaseAsset({
|
||||||
|
owner: 'owner',
|
||||||
|
repo: 'repo',
|
||||||
|
release_id: 42,
|
||||||
|
asset_id: 99,
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(
|
||||||
|
'Failed to delete release asset 99. GitHub endpoint: standard route not found; release-scoped endpoint: release-scoped route not found',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('passes previous_tag_name to generateReleaseNotes and strips it from createRelease', async () => {
|
it('passes previous_tag_name to generateReleaseNotes and strips it from createRelease', async () => {
|
||||||
const generateReleaseNotes = vi.fn(async () => ({
|
const generateReleaseNotes = vi.fn(async () => ({
|
||||||
data: {
|
data: {
|
||||||
@@ -692,6 +793,110 @@ describe('github', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('error handling', () => {
|
describe('error handling', () => {
|
||||||
|
it.each([
|
||||||
|
{
|
||||||
|
name: 'a remote repository without a discussion category',
|
||||||
|
repository: 'remote-owner/release-repo',
|
||||||
|
category: undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'the current repository without a discussion category',
|
||||||
|
repository: 'owner/repo',
|
||||||
|
category: undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'a repository with a discussion category',
|
||||||
|
repository: 'owner/repo',
|
||||||
|
category: 'Announcements',
|
||||||
|
},
|
||||||
|
])('classifies a create-release 404 for $name without retrying', async (testCase) => {
|
||||||
|
const releaseError = {
|
||||||
|
status: 404,
|
||||||
|
message: 'Not Found - create-a-release',
|
||||||
|
};
|
||||||
|
const log = vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
||||||
|
const createRelease = vi.fn().mockRejectedValue(releaseError);
|
||||||
|
const releaser = createReleaser({
|
||||||
|
getReleaseByTag: vi.fn().mockRejectedValue({ status: 404 }),
|
||||||
|
createRelease,
|
||||||
|
allReleases: async function* () {
|
||||||
|
yield { data: [] };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const thrown = await release(
|
||||||
|
{
|
||||||
|
...config,
|
||||||
|
github_repository: testCase.repository,
|
||||||
|
input_discussion_category_name: testCase.category,
|
||||||
|
},
|
||||||
|
releaser,
|
||||||
|
1,
|
||||||
|
).catch((error) => error);
|
||||||
|
|
||||||
|
expect(thrown).toMatchObject({
|
||||||
|
name: 'ReleaseCreationError',
|
||||||
|
status: 404,
|
||||||
|
cause: releaseError,
|
||||||
|
});
|
||||||
|
expect(thrown.message).toContain(
|
||||||
|
`GitHub returned 404 while creating the release. Verify that ${testCase.repository} exists under the expected owner`,
|
||||||
|
);
|
||||||
|
expect(thrown.message).toContain('the token can access it');
|
||||||
|
expect(thrown.message).toContain('fine-grained PAT');
|
||||||
|
expect(thrown.message).toContain('Contents: write');
|
||||||
|
expect(thrown.message).toContain('GitHub response: Not Found - create-a-release');
|
||||||
|
if (testCase.category) {
|
||||||
|
expect(thrown.message).toContain('Discussions and the requested category "Announcements"');
|
||||||
|
} else {
|
||||||
|
expect(thrown.message).not.toContain('discussion category mismatch');
|
||||||
|
expect(thrown.message).not.toContain('requested category');
|
||||||
|
}
|
||||||
|
expect(createRelease).toHaveBeenCalledOnce();
|
||||||
|
expect(log).not.toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('Unexpected error fetching GitHub release'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('classifies a draft-listing 404 as a repository access failure', async () => {
|
||||||
|
const listingError = {
|
||||||
|
status: 404,
|
||||||
|
message: 'Not Found - list-releases',
|
||||||
|
};
|
||||||
|
const createRelease = vi.fn();
|
||||||
|
const releaser = createReleaser({
|
||||||
|
getReleaseByTag: vi.fn().mockRejectedValue({ status: 404 }),
|
||||||
|
allReleases: async function* () {
|
||||||
|
throw listingError;
|
||||||
|
},
|
||||||
|
createRelease,
|
||||||
|
});
|
||||||
|
|
||||||
|
const thrown = await release(
|
||||||
|
{
|
||||||
|
...config,
|
||||||
|
github_repository: 'remote-owner/release-repo',
|
||||||
|
input_discussion_category_name: undefined,
|
||||||
|
},
|
||||||
|
releaser,
|
||||||
|
1,
|
||||||
|
).catch((error) => error);
|
||||||
|
|
||||||
|
expect(thrown).toMatchObject({
|
||||||
|
name: 'ReleaseAccessError',
|
||||||
|
status: 404,
|
||||||
|
cause: listingError,
|
||||||
|
});
|
||||||
|
expect(thrown.message).toContain('GitHub returned 404 while checking existing releases');
|
||||||
|
expect(thrown.message).toContain('remote-owner/release-repo');
|
||||||
|
expect(thrown.message).toContain('the token can access it');
|
||||||
|
expect(thrown.message).toContain('fine-grained PAT');
|
||||||
|
expect(thrown.message).toContain('Contents: write');
|
||||||
|
expect(thrown.message).toContain('GitHub response: Not Found - list-releases');
|
||||||
|
expect(thrown.message).not.toContain('discussion category mismatch');
|
||||||
|
expect(createRelease).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('reports a useful create error without assuming response data exists', async () => {
|
it('reports a useful create error without assuming response data exists', async () => {
|
||||||
const releaseError = {
|
const releaseError = {
|
||||||
status: 403,
|
status: 403,
|
||||||
@@ -945,6 +1150,7 @@ describe('github', () => {
|
|||||||
'https://uploads.github.com/repos/owner/repo/releases/1/assets',
|
'https://uploads.github.com/repos/owner/repo/releases/1/assets',
|
||||||
'__tests__/release.txt',
|
'__tests__/release.txt',
|
||||||
[],
|
[],
|
||||||
|
1,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(result).toStrictEqual({ id: 123, name: 'release.txt' });
|
expect(result).toStrictEqual({ id: 123, name: 'release.txt' });
|
||||||
@@ -956,11 +1162,52 @@ describe('github', () => {
|
|||||||
expect(deleteReleaseAsset).toHaveBeenCalledWith({
|
expect(deleteReleaseAsset).toHaveBeenCalledWith({
|
||||||
owner: 'owner',
|
owner: 'owner',
|
||||||
repo: 'repo',
|
repo: 'repo',
|
||||||
|
release_id: 1,
|
||||||
asset_id: 99,
|
asset_id: 99,
|
||||||
});
|
});
|
||||||
expect(uploadReleaseAsset).toHaveBeenCalledTimes(2);
|
expect(uploadReleaseAsset).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not upload until deletion of an existing asset succeeds', async () => {
|
||||||
|
const deletionError = new Error('asset deletion failed');
|
||||||
|
const uploadReleaseAsset = vi.fn();
|
||||||
|
const releaser = createReleaser({
|
||||||
|
deleteReleaseAsset: vi.fn().mockRejectedValue(deletionError),
|
||||||
|
uploadReleaseAsset,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
upload(
|
||||||
|
config,
|
||||||
|
releaser,
|
||||||
|
'https://uploads.github.com/repos/owner/repo/releases/1/assets',
|
||||||
|
'__tests__/release.txt',
|
||||||
|
[{ id: 99, name: 'release.txt' }],
|
||||||
|
1,
|
||||||
|
),
|
||||||
|
).rejects.toBe(deletionError);
|
||||||
|
expect(uploadReleaseAsset).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips deletion and upload when overwrite_files is false', async () => {
|
||||||
|
const deleteReleaseAsset = vi.fn();
|
||||||
|
const uploadReleaseAsset = vi.fn();
|
||||||
|
const releaser = createReleaser({ deleteReleaseAsset, uploadReleaseAsset });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
upload(
|
||||||
|
{ ...config, input_overwrite_files: false },
|
||||||
|
releaser,
|
||||||
|
'https://uploads.github.com/repos/owner/repo/releases/1/assets',
|
||||||
|
'__tests__/release.txt',
|
||||||
|
[{ id: 99, name: 'release.txt' }],
|
||||||
|
1,
|
||||||
|
),
|
||||||
|
).resolves.toBeNull();
|
||||||
|
expect(deleteReleaseAsset).not.toHaveBeenCalled();
|
||||||
|
expect(uploadReleaseAsset).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('surfaces an actionable immutable-release error for prerelease uploads', async () => {
|
it('surfaces an actionable immutable-release error for prerelease uploads', async () => {
|
||||||
const tempDir = mkdtempSync(join(tmpdir(), 'gh-release-immutable-'));
|
const tempDir = mkdtempSync(join(tmpdir(), 'gh-release-immutable-'));
|
||||||
const assetPath = join(tempDir, 'draft-false.txt');
|
const assetPath = join(tempDir, 'draft-false.txt');
|
||||||
@@ -1000,6 +1247,7 @@ describe('github', () => {
|
|||||||
'https://uploads.github.com/repos/owner/repo/releases/1/assets',
|
'https://uploads.github.com/repos/owner/repo/releases/1/assets',
|
||||||
assetPath,
|
assetPath,
|
||||||
[],
|
[],
|
||||||
|
1,
|
||||||
),
|
),
|
||||||
).rejects.toThrow(
|
).rejects.toThrow(
|
||||||
'Cannot upload asset draft-false.txt to an immutable release. GitHub only allows asset uploads before a release is published, but draft prereleases publish with the release.published event instead of release.prereleased.',
|
'Cannot upload asset draft-false.txt to an immutable release. GitHub only allows asset uploads before a release is published, but draft prereleases publish with the release.published event instead of release.prereleased.',
|
||||||
@@ -1054,6 +1302,7 @@ describe('github', () => {
|
|||||||
'https://uploads.github.com/repos/owner/repo/releases/1/assets',
|
'https://uploads.github.com/repos/owner/repo/releases/1/assets',
|
||||||
dotfilePath,
|
dotfilePath,
|
||||||
[],
|
[],
|
||||||
|
1,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(result).toStrictEqual({ id: 123, name: 'default.config', label: '.config' });
|
expect(result).toStrictEqual({ id: 123, name: 'default.config', label: '.config' });
|
||||||
@@ -1065,6 +1314,7 @@ describe('github', () => {
|
|||||||
expect(deleteReleaseAsset).toHaveBeenCalledWith({
|
expect(deleteReleaseAsset).toHaveBeenCalledWith({
|
||||||
owner: 'owner',
|
owner: 'owner',
|
||||||
repo: 'repo',
|
repo: 'repo',
|
||||||
|
release_id: 1,
|
||||||
asset_id: 99,
|
asset_id: 99,
|
||||||
});
|
});
|
||||||
expect(updateReleaseAsset).toHaveBeenCalledWith({
|
expect(updateReleaseAsset).toHaveBeenCalledWith({
|
||||||
@@ -1512,6 +1762,7 @@ describe('github', () => {
|
|||||||
'https://uploads.example.test/assets',
|
'https://uploads.example.test/assets',
|
||||||
dotfilePath,
|
dotfilePath,
|
||||||
[],
|
[],
|
||||||
|
1,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(updateReleaseAssetSpy).toHaveBeenCalledWith({
|
expect(updateReleaseAssetSpy).toHaveBeenCalledWith({
|
||||||
@@ -1583,6 +1834,7 @@ describe('github', () => {
|
|||||||
'https://uploads.github.com/repos/owner/repo/releases/1/assets',
|
'https://uploads.github.com/repos/owner/repo/releases/1/assets',
|
||||||
dotfilePath,
|
dotfilePath,
|
||||||
[],
|
[],
|
||||||
|
1,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(updateReleaseAssetSpy).toHaveBeenNthCalledWith(1, {
|
expect(updateReleaseAssetSpy).toHaveBeenNthCalledWith(1, {
|
||||||
@@ -1653,6 +1905,7 @@ describe('github', () => {
|
|||||||
'https://uploads.github.com/repos/owner/repo/releases/1/assets',
|
'https://uploads.github.com/repos/owner/repo/releases/1/assets',
|
||||||
dotfilePath,
|
dotfilePath,
|
||||||
[],
|
[],
|
||||||
|
1,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(listReleaseAssetsSpy).toHaveBeenCalledWith({
|
expect(listReleaseAssetsSpy).toHaveBeenCalledWith({
|
||||||
@@ -1712,6 +1965,7 @@ describe('github', () => {
|
|||||||
'https://uploads.github.com/repos/owner/repo/releases/1/assets',
|
'https://uploads.github.com/repos/owner/repo/releases/1/assets',
|
||||||
dotfilePath,
|
dotfilePath,
|
||||||
[],
|
[],
|
||||||
|
1,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(listReleaseAssetsSpy).toHaveBeenCalledWith({
|
expect(listReleaseAssetsSpy).toHaveBeenCalledWith({
|
||||||
@@ -1772,6 +2026,7 @@ describe('github', () => {
|
|||||||
'https://uploads.github.com/repos/owner/repo/releases/1/assets',
|
'https://uploads.github.com/repos/owner/repo/releases/1/assets',
|
||||||
dotfilePath,
|
dotfilePath,
|
||||||
[],
|
[],
|
||||||
|
1,
|
||||||
);
|
);
|
||||||
|
|
||||||
await vi.waitFor(() => expect(listReleaseAssetsSpy).toHaveBeenCalledTimes(1));
|
await vi.waitFor(() => expect(listReleaseAssetsSpy).toHaveBeenCalledTimes(1));
|
||||||
@@ -1827,18 +2082,26 @@ describe('github', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await upload(config, releaser, 'https://uploads.example.test/assets', dotfilePath, [
|
await upload(
|
||||||
|
config,
|
||||||
|
releaser,
|
||||||
|
'https://uploads.example.test/assets',
|
||||||
|
dotfilePath,
|
||||||
|
[
|
||||||
{
|
{
|
||||||
id: 1,
|
id: 1,
|
||||||
name: 'default.config',
|
name: 'default.config',
|
||||||
label: '.config',
|
label: '.config',
|
||||||
},
|
},
|
||||||
]);
|
],
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
|
||||||
expect(deleteReleaseAssetSpy).toHaveBeenCalledWith({
|
expect(deleteReleaseAssetSpy).toHaveBeenCalledWith({
|
||||||
asset_id: 1,
|
asset_id: 1,
|
||||||
owner: 'owner',
|
owner: 'owner',
|
||||||
repo: 'repo',
|
repo: 'repo',
|
||||||
|
release_id: 1,
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
rmSync(tempDir, { recursive: true, force: true });
|
rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
|||||||
@@ -0,0 +1,182 @@
|
|||||||
|
import { getOctokit } from '@actions/github';
|
||||||
|
import { createServer, type Server } from 'http';
|
||||||
|
import { type AddressInfo } from 'net';
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
import { GitHubReleaser, release, type Release } from '../src/github';
|
||||||
|
import { parseConfig, type Config } from '../src/util';
|
||||||
|
|
||||||
|
type CapturedRequest = {
|
||||||
|
path: string;
|
||||||
|
authorization: string | undefined;
|
||||||
|
contentType: string | undefined;
|
||||||
|
body: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeServer = async (server: Server): Promise<void> => {
|
||||||
|
server.closeIdleConnections();
|
||||||
|
server.closeAllConnections();
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
server.close((error) => (error ? reject(error) : resolve()));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('release creation transport', () => {
|
||||||
|
let server: Server;
|
||||||
|
let baseUrl: string;
|
||||||
|
const requests: CapturedRequest[] = [];
|
||||||
|
const releases = new Map<string, Release>();
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
server = createServer(async (request, response) => {
|
||||||
|
const url = new URL(request.url || '/', 'http://127.0.0.1');
|
||||||
|
const tagMatch = url.pathname.match(/^\/repos\/owner\/remote\/releases\/tags\/(.+)$/);
|
||||||
|
if (request.method === 'GET' && tagMatch) {
|
||||||
|
const release = releases.get(decodeURIComponent(tagMatch[1]));
|
||||||
|
response.writeHead(release ? 200 : 404, { 'content-type': 'application/json' });
|
||||||
|
response.end(JSON.stringify(release ?? { message: 'Not Found' }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.method === 'GET' && url.pathname === '/repos/owner/remote/releases') {
|
||||||
|
response.writeHead(200, { 'content-type': 'application/json' });
|
||||||
|
response.end(JSON.stringify([...releases.values()]));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.method === 'POST' && url.pathname === '/repos/owner/remote/releases') {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
for await (const chunk of request) {
|
||||||
|
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||||
|
}
|
||||||
|
const body = JSON.parse(Buffer.concat(chunks).toString('utf8')) as Record<string, unknown>;
|
||||||
|
requests.push({
|
||||||
|
path: url.pathname,
|
||||||
|
authorization: request.headers.authorization,
|
||||||
|
contentType: request.headers['content-type'],
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
const tag = String(body.tag_name);
|
||||||
|
const createdRelease: Release = {
|
||||||
|
id: requests.length,
|
||||||
|
upload_url: `http://127.0.0.1/uploads/${requests.length}`,
|
||||||
|
html_url: `http://127.0.0.1/releases/${requests.length}`,
|
||||||
|
tag_name: tag,
|
||||||
|
name: String(body.name),
|
||||||
|
body: typeof body.body === 'string' ? body.body : null,
|
||||||
|
target_commitish: 'main',
|
||||||
|
draft: Boolean(body.draft),
|
||||||
|
prerelease: Boolean(body.prerelease),
|
||||||
|
assets: [],
|
||||||
|
};
|
||||||
|
releases.set(tag, createdRelease);
|
||||||
|
response.writeHead(201, { 'content-type': 'application/json' });
|
||||||
|
response.end(JSON.stringify(createdRelease));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
response.writeHead(500, { 'content-type': 'application/json' });
|
||||||
|
response.end(
|
||||||
|
JSON.stringify({ message: `Unexpected route ${request.method} ${url.pathname}` }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
server.once('error', reject);
|
||||||
|
server.listen(0, '127.0.0.1', resolve);
|
||||||
|
});
|
||||||
|
const address = server.address() as AddressInfo;
|
||||||
|
baseUrl = `http://127.0.0.1:${address.port}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await closeServer(server);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('serializes user-facing category inputs through the real Octokit request path', async () => {
|
||||||
|
const parsedEmptyCategory = parseConfig({
|
||||||
|
INPUT_DISCUSSION_CATEGORY_NAME: '',
|
||||||
|
}).input_discussion_category_name;
|
||||||
|
expect(parsedEmptyCategory).toBeUndefined();
|
||||||
|
|
||||||
|
const cases: Array<{
|
||||||
|
name: string;
|
||||||
|
categoryProperty: 'absent' | 'present';
|
||||||
|
category: string | undefined;
|
||||||
|
expectedCategory: string | undefined;
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
name: 'absent-category',
|
||||||
|
categoryProperty: 'absent',
|
||||||
|
category: undefined,
|
||||||
|
expectedCategory: undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'undefined-category',
|
||||||
|
categoryProperty: 'present',
|
||||||
|
category: undefined,
|
||||||
|
expectedCategory: undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'empty-input-category',
|
||||||
|
categoryProperty: 'present',
|
||||||
|
category: parsedEmptyCategory,
|
||||||
|
expectedCategory: undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'valid-category',
|
||||||
|
categoryProperty: 'present',
|
||||||
|
category: 'Announcements',
|
||||||
|
expectedCategory: 'Announcements',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const testCase of cases) {
|
||||||
|
const config: Config = {
|
||||||
|
github_token: 'not-a-real-token',
|
||||||
|
github_ref: 'refs/heads/main',
|
||||||
|
github_repository: 'owner/remote',
|
||||||
|
input_tag_name: testCase.name,
|
||||||
|
input_name: `Release ${testCase.name}`,
|
||||||
|
input_files: [],
|
||||||
|
input_draft: false,
|
||||||
|
input_prerelease: true,
|
||||||
|
input_fail_on_unmatched_files: false,
|
||||||
|
input_generate_release_notes: false,
|
||||||
|
input_append_body: false,
|
||||||
|
input_make_latest: undefined,
|
||||||
|
};
|
||||||
|
if (testCase.categoryProperty === 'present') {
|
||||||
|
config.input_discussion_category_name = testCase.category;
|
||||||
|
}
|
||||||
|
|
||||||
|
const releaser = new GitHubReleaser(
|
||||||
|
getOctokit(config.github_token, {
|
||||||
|
baseUrl,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await expect(release(config, releaser, 1)).resolves.toMatchObject({
|
||||||
|
release: { tag_name: testCase.name },
|
||||||
|
created: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const request = requests.at(-1);
|
||||||
|
expect(request).toMatchObject({
|
||||||
|
path: '/repos/owner/remote/releases',
|
||||||
|
authorization: 'token not-a-real-token',
|
||||||
|
contentType: 'application/json; charset=utf-8',
|
||||||
|
body: {
|
||||||
|
tag_name: testCase.name,
|
||||||
|
name: `Release ${testCase.name}`,
|
||||||
|
draft: false,
|
||||||
|
prerelease: true,
|
||||||
|
generate_release_notes: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (testCase.expectedCategory) {
|
||||||
|
expect(request?.body.discussion_category_name).toBe(testCase.expectedCategory);
|
||||||
|
} else {
|
||||||
|
expect(request?.body).not.toHaveProperty('discussion_category_name');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -211,6 +211,7 @@ describe('run', () => {
|
|||||||
'https://uploads.example.test/releases/41/assets',
|
'https://uploads.example.test/releases/41/assets',
|
||||||
'fixture/dist/action.zip',
|
'fixture/dist/action.zip',
|
||||||
initialRelease.assets,
|
initialRelease.assets,
|
||||||
|
initialRelease.id,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -205,7 +205,7 @@ describe('release asset upload transport', () => {
|
|||||||
openFile.mockClear();
|
openFile.mockClear();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await expect(upload(config, releaser, uploadUrl, fixture.path, [])).resolves.toEqual({
|
await expect(upload(config, releaser, uploadUrl, fixture.path, [], 1)).resolves.toEqual({
|
||||||
id: 123,
|
id: 123,
|
||||||
name: fixture.name,
|
name: fixture.name,
|
||||||
});
|
});
|
||||||
@@ -243,7 +243,7 @@ describe('release asset upload transport', () => {
|
|||||||
openFile.mockClear();
|
openFile.mockClear();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await expect(upload(config, releaser, uploadUrl, fixture.path, [])).rejects.toThrow(
|
await expect(upload(config, releaser, uploadUrl, fixture.path, [], 1)).rejects.toThrow(
|
||||||
'Validation Failed',
|
'Validation Failed',
|
||||||
);
|
);
|
||||||
await expectLastFileHandleClosed();
|
await expectLastFileHandleClosed();
|
||||||
@@ -275,7 +275,7 @@ describe('release asset upload transport', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await expect(upload(config, releaser, uploadUrl, fixture.path, [])).resolves.toEqual({
|
await expect(upload(config, releaser, uploadUrl, fixture.path, [], 1)).resolves.toEqual({
|
||||||
id: 123,
|
id: 123,
|
||||||
name: fixture.name,
|
name: fixture.name,
|
||||||
});
|
});
|
||||||
@@ -297,11 +297,13 @@ describe('release asset upload transport', () => {
|
|||||||
);
|
);
|
||||||
const releaser = new GitHubReleaser(getOctokit(config.github_token));
|
const releaser = new GitHubReleaser(getOctokit(config.github_token));
|
||||||
vi.spyOn(releaser, 'listReleaseAssets').mockResolvedValue([{ id: 9, name: fixture.name }]);
|
vi.spyOn(releaser, 'listReleaseAssets').mockResolvedValue([{ id: 9, name: fixture.name }]);
|
||||||
vi.spyOn(releaser, 'deleteReleaseAsset').mockResolvedValue(undefined);
|
const deleteReleaseAsset = vi
|
||||||
|
.spyOn(releaser, 'deleteReleaseAsset')
|
||||||
|
.mockResolvedValue(undefined);
|
||||||
openFile.mockClear();
|
openFile.mockClear();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await expect(upload(config, releaser, uploadUrl, fixture.path, [])).resolves.toEqual({
|
await expect(upload(config, releaser, uploadUrl, fixture.path, [], 1)).resolves.toEqual({
|
||||||
id: 123,
|
id: 123,
|
||||||
name: fixture.name,
|
name: fixture.name,
|
||||||
});
|
});
|
||||||
@@ -309,6 +311,12 @@ describe('release asset upload transport', () => {
|
|||||||
expect(receipts.map(({ size }) => size)).toEqual([fixture.size, fixture.size]);
|
expect(receipts.map(({ size }) => size)).toEqual([fixture.size, fixture.size]);
|
||||||
expect(receipts.map(({ digest }) => digest)).toEqual([fixture.digest, fixture.digest]);
|
expect(receipts.map(({ digest }) => digest)).toEqual([fixture.digest, fixture.digest]);
|
||||||
expect(openFile).toHaveBeenCalledTimes(2);
|
expect(openFile).toHaveBeenCalledTimes(2);
|
||||||
|
expect(deleteReleaseAsset).toHaveBeenCalledWith({
|
||||||
|
owner: 'owner',
|
||||||
|
repo: 'repo',
|
||||||
|
release_id: 1,
|
||||||
|
asset_id: 9,
|
||||||
|
});
|
||||||
for (const result of openFile.mock.results) {
|
for (const result of openFile.mock.results) {
|
||||||
const fileHandle = await result.value;
|
const fileHandle = await result.value;
|
||||||
await expect(fileHandle.stat()).rejects.toMatchObject({ code: 'EBADF' });
|
await expect(fileHandle.stat()).rejects.toMatchObject({ code: 'EBADF' });
|
||||||
@@ -317,4 +325,70 @@ describe('release asset upload transport', () => {
|
|||||||
await closeServer(server);
|
await closeServer(server);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('falls back to the release-scoped delete route through the real Octokit request path', async () => {
|
||||||
|
const requests: Array<{
|
||||||
|
method: string | undefined;
|
||||||
|
pathname: string;
|
||||||
|
authorization: string | undefined;
|
||||||
|
}> = [];
|
||||||
|
const server = createServer((request, response) => {
|
||||||
|
const url = new URL(request.url || '/', 'http://127.0.0.1');
|
||||||
|
requests.push({
|
||||||
|
method: request.method,
|
||||||
|
pathname: url.pathname,
|
||||||
|
authorization: request.headers.authorization,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (url.pathname === '/repos/owner/repo/releases/assets/9') {
|
||||||
|
response.writeHead(404, { 'content-type': 'application/json' });
|
||||||
|
response.end(JSON.stringify({ message: 'page not found' }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (url.pathname === '/repos/owner/repo/releases/1/assets/9') {
|
||||||
|
response.writeHead(204);
|
||||||
|
response.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
response.writeHead(500, { 'content-type': 'application/json' });
|
||||||
|
response.end(JSON.stringify({ message: 'unexpected route' }));
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
server.once('error', reject);
|
||||||
|
server.listen(0, '127.0.0.1', resolve);
|
||||||
|
});
|
||||||
|
const address = server.address() as AddressInfo;
|
||||||
|
const releaser = new GitHubReleaser(
|
||||||
|
getOctokit(config.github_token, {
|
||||||
|
baseUrl: `http://127.0.0.1:${address.port}`,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await expect(
|
||||||
|
releaser.deleteReleaseAsset({
|
||||||
|
owner: 'owner',
|
||||||
|
repo: 'repo',
|
||||||
|
release_id: 1,
|
||||||
|
asset_id: 9,
|
||||||
|
}),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
expect(requests).toEqual([
|
||||||
|
{
|
||||||
|
method: 'DELETE',
|
||||||
|
pathname: '/repos/owner/repo/releases/assets/9',
|
||||||
|
authorization: 'token not-a-real-token',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'DELETE',
|
||||||
|
pathname: '/repos/owner/repo/releases/1/assets/9',
|
||||||
|
authorization: 'token not-a-real-token',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
await closeServer(server);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Vendored
+29
-29
File diff suppressed because one or more lines are too long
+83
-6
@@ -68,6 +68,43 @@ type ReleaseMutationParams = {
|
|||||||
previous_tag_name?: string;
|
previous_tag_name?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
class ReleaseCreationError extends Error {
|
||||||
|
readonly status = 404;
|
||||||
|
|
||||||
|
constructor(message: string, cause: unknown) {
|
||||||
|
super(message, { cause });
|
||||||
|
this.name = 'ReleaseCreationError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ReleaseAccessError extends Error {
|
||||||
|
readonly status = 404;
|
||||||
|
|
||||||
|
constructor(message: string, cause: unknown) {
|
||||||
|
super(message, { cause });
|
||||||
|
this.name = 'ReleaseAccessError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const repositoryAccessGuidance = (owner: string, repo: string): string =>
|
||||||
|
`Verify that ${owner}/${repo} exists under the expected owner, the token can access it, the repository is selected when using a fine-grained PAT, and the token has Contents: write permission.`;
|
||||||
|
|
||||||
|
const releaseCreation404Message = (
|
||||||
|
owner: string,
|
||||||
|
repo: string,
|
||||||
|
discussionCategory: string | undefined,
|
||||||
|
error: unknown,
|
||||||
|
): string => {
|
||||||
|
const discussionGuidance = discussionCategory
|
||||||
|
? ` Also verify that Discussions and the requested category "${discussionCategory}" are enabled.`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return `GitHub returned 404 while creating the release. ${repositoryAccessGuidance(owner, repo)}${discussionGuidance} GitHub response: ${errorMessage(error)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const releaseLookup404Message = (owner: string, repo: string, error: unknown): string =>
|
||||||
|
`GitHub returned 404 while checking existing releases. ${repositoryAccessGuidance(owner, repo)} GitHub response: ${errorMessage(error)}`;
|
||||||
|
|
||||||
export interface Releaser {
|
export interface Releaser {
|
||||||
getReleaseByTag(params: { owner: string; repo: string; tag: string }): Promise<{ data: Release }>;
|
getReleaseByTag(params: { owner: string; repo: string; tag: string }): Promise<{ data: Release }>;
|
||||||
|
|
||||||
@@ -96,7 +133,12 @@ export interface Releaser {
|
|||||||
release_id: number;
|
release_id: number;
|
||||||
}): Promise<Array<{ id: number; name: string; label?: string | null; [key: string]: any }>>;
|
}): Promise<Array<{ id: number; name: string; label?: string | null; [key: string]: any }>>;
|
||||||
|
|
||||||
deleteReleaseAsset(params: { owner: string; repo: string; asset_id: number }): Promise<void>;
|
deleteReleaseAsset(params: {
|
||||||
|
owner: string;
|
||||||
|
repo: string;
|
||||||
|
release_id: number;
|
||||||
|
asset_id: number;
|
||||||
|
}): Promise<void>;
|
||||||
|
|
||||||
deleteRelease(params: { owner: string; repo: string; release_id: number }): Promise<void>;
|
deleteRelease(params: { owner: string; repo: string; release_id: number }): Promise<void>;
|
||||||
|
|
||||||
@@ -229,9 +271,34 @@ export class GitHubReleaser implements Releaser {
|
|||||||
async deleteReleaseAsset(params: {
|
async deleteReleaseAsset(params: {
|
||||||
owner: string;
|
owner: string;
|
||||||
repo: string;
|
repo: string;
|
||||||
|
release_id: number;
|
||||||
asset_id: number;
|
asset_id: number;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
await this.github.rest.repos.deleteReleaseAsset(params);
|
const { release_id, ...githubParams } = params;
|
||||||
|
try {
|
||||||
|
await this.github.rest.repos.deleteReleaseAsset(githubParams);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const status =
|
||||||
|
(error as { status?: number; response?: { status?: number } })?.status ??
|
||||||
|
(error as { response?: { status?: number } })?.response?.status;
|
||||||
|
if (status !== 404) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.github.request(
|
||||||
|
'DELETE /repos/{owner}/{repo}/releases/{release_id}/assets/{asset_id}',
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
} catch (fallbackError: unknown) {
|
||||||
|
throw new AggregateError(
|
||||||
|
[error, fallbackError],
|
||||||
|
`Failed to delete release asset ${params.asset_id}. GitHub endpoint: ${errorMessage(
|
||||||
|
error,
|
||||||
|
)}; release-scoped endpoint: ${errorMessage(fallbackError)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteRelease(params: { owner: string; repo: string; release_id: number }): Promise<void> {
|
async deleteRelease(params: { owner: string; repo: string; release_id: number }): Promise<void> {
|
||||||
@@ -321,11 +388,10 @@ export const upload = async (
|
|||||||
url: string,
|
url: string,
|
||||||
path: string,
|
path: string,
|
||||||
currentAssets: Array<{ id: number; name: string; label?: string | null }>,
|
currentAssets: Array<{ id: number; name: string; label?: string | null }>,
|
||||||
|
releaseId: number,
|
||||||
): Promise<any> => {
|
): Promise<any> => {
|
||||||
const [owner, repo] = config.github_repository.split('/');
|
const [owner, repo] = config.github_repository.split('/');
|
||||||
const { name, mime, size } = asset(path);
|
const { name, mime, size } = asset(path);
|
||||||
const releaseIdMatch = url.match(/\/releases\/(\d+)\/assets/);
|
|
||||||
const releaseId = releaseIdMatch ? Number(releaseIdMatch[1]) : undefined;
|
|
||||||
const currentAsset = currentAssets.find(
|
const currentAsset = currentAssets.find(
|
||||||
// GitHub can rewrite uploaded asset names, so compare against both the raw name
|
// GitHub can rewrite uploaded asset names, so compare against both the raw name
|
||||||
// GitHub returns and the restored label we set when available.
|
// GitHub returns and the restored label we set when available.
|
||||||
@@ -341,6 +407,7 @@ export const upload = async (
|
|||||||
asset_id: currentAsset.id || 1,
|
asset_id: currentAsset.id || 1,
|
||||||
owner,
|
owner,
|
||||||
repo,
|
repo,
|
||||||
|
release_id: releaseId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -499,6 +566,7 @@ export const upload = async (
|
|||||||
await releaser.deleteReleaseAsset({
|
await releaser.deleteReleaseAsset({
|
||||||
owner,
|
owner,
|
||||||
repo,
|
repo,
|
||||||
|
release_id: releaseId,
|
||||||
asset_id: latestAsset.id,
|
asset_id: latestAsset.id,
|
||||||
});
|
});
|
||||||
return await handleUploadedAsset(await uploadAsset());
|
return await handleUploadedAsset(await uploadAsset());
|
||||||
@@ -535,6 +603,11 @@ export const release = async (
|
|||||||
try {
|
try {
|
||||||
_release = await findTagFromReleases(releaser, owner, repo, tag, maxRetries);
|
_release = await findTagFromReleases(releaser, owner, repo, tag, maxRetries);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error.status === 404) {
|
||||||
|
const diagnostic = releaseLookup404Message(owner, repo, error);
|
||||||
|
console.log(`⚠️ ${diagnostic}`);
|
||||||
|
throw new ReleaseAccessError(diagnostic, error);
|
||||||
|
}
|
||||||
console.log(
|
console.log(
|
||||||
`⚠️ Unexpected error fetching GitHub release for tag ${config.github_ref}: ${error}`,
|
`⚠️ Unexpected error fetching GitHub release for tag ${config.github_ref}: ${error}`,
|
||||||
);
|
);
|
||||||
@@ -613,6 +686,9 @@ export const release = async (
|
|||||||
created: false,
|
created: false,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error instanceof ReleaseCreationError) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
if (error.status !== 404) {
|
if (error.status !== 404) {
|
||||||
console.log(
|
console.log(
|
||||||
`⚠️ Unexpected error fetching GitHub release for tag ${config.github_ref}: ${error}`,
|
`⚠️ Unexpected error fetching GitHub release for tag ${config.github_ref}: ${error}`,
|
||||||
@@ -998,8 +1074,9 @@ async function createRelease(
|
|||||||
throw error;
|
throw error;
|
||||||
|
|
||||||
case 404:
|
case 404:
|
||||||
console.log('Skip retry - discussion category mismatch');
|
const diagnostic = releaseCreation404Message(owner, repo, discussion_category_name, error);
|
||||||
throw error;
|
console.log(`Skip retry — ${diagnostic}`);
|
||||||
|
throw new ReleaseCreationError(diagnostic, error);
|
||||||
|
|
||||||
case 422:
|
case 422:
|
||||||
// Check if this is a race condition with "already_exists" error
|
// Check if this is a race condition with "already_exists" error
|
||||||
|
|||||||
+8
-1
@@ -52,7 +52,14 @@ export async function run(): Promise<void> {
|
|||||||
const currentAssets = rel.assets;
|
const currentAssets = rel.assets;
|
||||||
|
|
||||||
const uploadFile = async (path: string) => {
|
const uploadFile = async (path: string) => {
|
||||||
const json = await upload(config, releaser, uploadUrl(rel.upload_url), path, currentAssets);
|
const json = await upload(
|
||||||
|
config,
|
||||||
|
releaser,
|
||||||
|
uploadUrl(rel.upload_url),
|
||||||
|
path,
|
||||||
|
currentAssets,
|
||||||
|
rel.id,
|
||||||
|
);
|
||||||
return json ? (json.id as number) : undefined;
|
return json ? (json.id as number) : undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user