fix: safely classify GitHub API errors (#822)

* refactor: harden GitHub error classification

Signed-off-by: Rui Chen <rui@chenrui.dev>

* test: define nested GitHub status handling

Signed-off-by: Rui Chen <rui@chenrui.dev>

---------

Signed-off-by: Rui Chen <rui@chenrui.dev>
This commit is contained in:
Rui Chen
2026-07-15 11:06:56 -04:00
committed by GitHub
parent d100e75a45
commit 1c214ab373
5 changed files with 560 additions and 105 deletions
+461 -36
View File
@@ -149,6 +149,55 @@ describe('github', () => {
expect(pageAfterMatch).not.toHaveBeenCalled(); expect(pageAfterMatch).not.toHaveBeenCalled();
}); });
it.each([
['a top-level 404', { status: 404 }],
['a nested response 404', { response: { status: 404 } }],
[
'an invalid top-level status with a nested response 404',
{ status: '500', response: { status: 404 } },
],
])('uses release-list fallback for %s', async (_name, lookupError) => {
const draftRelease = { ...mockRelease, draft: true };
const allReleases = vi.fn(async function* () {
yield { data: [draftRelease] };
});
const releaser = {
...mockReleaser,
getReleaseByTag: vi.fn().mockRejectedValue(lookupError),
allReleases,
};
await expect(findTagFromReleases(releaser, owner, repo, draftRelease.tag_name)).resolves.toBe(
draftRelease,
);
expect(allReleases).toHaveBeenCalledOnce();
});
it.each([
['a nested response 500', { response: { status: 500 } }],
['a string top-level status', { status: '404' }],
['a string nested response status', { response: { status: '404' } }],
['a status-less object', { reason: 'lookup failed' }],
[
'a numeric top-level 500 with a nested response 404',
{ status: 500, response: { status: 404 } },
],
])('does not use release-list fallback for %s', async (_name, lookupError) => {
const allReleases = vi.fn(async function* () {
yield { data: [mockRelease] };
});
const releaser = {
...mockReleaser,
getReleaseByTag: vi.fn().mockRejectedValue(lookupError),
allReleases,
};
await expect(findTagFromReleases(releaser, owner, repo, mockRelease.tag_name)).rejects.toBe(
lookupError,
);
expect(allReleases).not.toHaveBeenCalled();
});
it('does not exhaust pagination while checking a brand-new tag', async () => { it('does not exhaust pagination while checking a brand-new tag', async () => {
let pagesRead = 0; let pagesRead = 0;
const releaser = { const releaser = {
@@ -346,11 +395,20 @@ describe('github', () => {
expect(request).not.toHaveBeenCalled(); expect(request).not.toHaveBeenCalled();
}); });
it('falls back to release-scoped asset deletion after a standard 404', async () => { it.each([
const deleteReleaseAsset = vi.fn().mockRejectedValue({ {
status: 404, name: 'top-level status',
message: 'standard route not found', deletionError: { status: 404, message: 'standard route not found' },
}); },
{
name: 'nested response status',
deletionError: {
response: { status: 404 },
message: 'standard route not found',
},
},
])('falls back to release-scoped asset deletion after a 404 with $name', async (testCase) => {
const deleteReleaseAsset = vi.fn().mockRejectedValue(testCase.deletionError);
const request = vi.fn().mockResolvedValue({ status: 204 }); const request = vi.fn().mockResolvedValue({ status: 204 });
const releaser = new GitHubReleaser({ const releaser = new GitHubReleaser({
rest: { repos: { deleteReleaseAsset } }, rest: { repos: { deleteReleaseAsset } },
@@ -376,6 +434,30 @@ describe('github', () => {
); );
}); });
it.each([
['a string', 'not found'],
['null', null],
['an arbitrary object', { response: { data: 'not found' } }],
])('does not misclassify %s as a fallback 404', async (_name, deletionError) => {
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('does not mask non-404 asset deletion failures', async () => { it('does not mask non-404 asset deletion failures', async () => {
const deletionError = { status: 403, message: 'forbidden' }; const deletionError = { status: 403, message: 'forbidden' };
const request = vi.fn(); const request = vi.fn();
@@ -790,6 +872,83 @@ describe('github', () => {
expect(finalizeReleaseSpy).toHaveBeenCalledTimes(1); expect(finalizeReleaseSpy).toHaveBeenCalledTimes(1);
expect(deleteReleaseSpy).not.toHaveBeenCalled(); expect(deleteReleaseSpy).not.toHaveBeenCalled();
}); });
it('retries malformed tag-rule errors without deleting the draft', async () => {
const finalizeReleaseSpy = vi.fn().mockRejectedValue({
status: 422,
response: {
data: {
errors: [null, 'invalid', { field: 'pre_receive', message: 42 }],
},
},
});
const deleteReleaseSpy = vi.fn();
const releaser = createReleaser({
finalizeRelease: finalizeReleaseSpy,
deleteRelease: deleteReleaseSpy,
});
await expect(
finalizeRelease(
{
...config,
input_draft: false,
},
releaser,
draftRelease,
true,
1,
),
).rejects.toThrow('Too many retries.');
expect(finalizeReleaseSpy).toHaveBeenCalledOnce();
expect(deleteReleaseSpy).not.toHaveBeenCalled();
});
it('finds a valid tag-rule violation after malformed validation entries', async () => {
const finalizeReleaseSpy = vi.fn().mockRejectedValue({
response: {
status: 422,
data: {
errors: [
null,
'invalid',
{
field: 'pre_receive',
message: 'Cannot create ref due to creations being restricted.',
},
],
},
},
});
const deleteReleaseSpy = vi.fn().mockResolvedValue(undefined);
const releaser = createReleaser({
finalizeRelease: finalizeReleaseSpy,
deleteRelease: deleteReleaseSpy,
});
await expect(
finalizeRelease(
{
...config,
input_draft: false,
},
releaser,
draftRelease,
true,
1,
),
).rejects.toThrow(
'Tag creation for v1.0.0 is blocked by repository rules. Deleted draft release 1 to avoid leaving an orphaned draft release.',
);
expect(finalizeReleaseSpy).toHaveBeenCalledOnce();
expect(deleteReleaseSpy).toHaveBeenCalledWith({
owner: 'owner',
repo: 'repo',
release_id: draftRelease.id,
});
});
}); });
describe('error handling', () => { describe('error handling', () => {
@@ -897,6 +1056,52 @@ describe('github', () => {
expect(createRelease).not.toHaveBeenCalled(); expect(createRelease).not.toHaveBeenCalled();
}); });
it.each([
{
name: 'a nested response 404 as repository access failure',
listingError: {
response: { status: 404 },
message: 'Not Found - list-releases',
},
expectAccessError: true,
},
{
name: 'a nested response 500 as the original error',
listingError: {
response: { status: 500 },
message: 'Server Error - list-releases',
},
expectAccessError: false,
},
{
name: 'a status-less object as the original error',
listingError: { reason: 'release listing failed' },
expectAccessError: false,
},
])('classifies a release-list fallback error with $name', async (testCase) => {
const createRelease = vi.fn();
const releaser = createReleaser({
getReleaseByTag: vi.fn().mockRejectedValue({ status: 404 }),
allReleases: async function* () {
throw testCase.listingError;
},
createRelease,
});
const thrown = await release(config, releaser, 1).catch((error) => error);
if (testCase.expectAccessError) {
expect(thrown).toMatchObject({
name: 'ReleaseAccessError',
status: 404,
cause: testCase.listingError,
});
} else {
expect(thrown).toBe(testCase.listingError);
}
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,
@@ -917,6 +1122,100 @@ describe('github', () => {
expect(log).not.toHaveBeenCalledWith('undefined'); expect(log).not.toHaveBeenCalledWith('undefined');
}); });
it.each([
{
name: 'a nested response 403',
releaseError: {
response: { status: 403 },
message: 'Resource not accessible by integration',
},
},
{
name: 'a nested response 422 without already_exists',
releaseError: {
response: { status: 422, data: { errors: [{ code: 'invalid' }] } },
message: 'Validation Failed',
},
},
])('does not retry release creation for $name', async (testCase) => {
const createRelease = vi.fn().mockRejectedValue(testCase.releaseError);
const releaser = createReleaser({
getReleaseByTag: vi.fn().mockRejectedValue({ status: 404 }),
createRelease,
allReleases: async function* () {
yield { data: [] };
},
});
await expect(release(config, releaser, 1)).rejects.toBe(testCase.releaseError);
expect(createRelease).toHaveBeenCalledOnce();
});
it('classifies a nested response 404 from release creation without retrying', async () => {
const releaseError = {
response: { status: 404 },
message: 'Not Found - create-a-release',
};
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, 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');
expect(createRelease).toHaveBeenCalledOnce();
});
it.each([
['non-object response data', { status: 422, response: { data: 'invalid' } }],
['missing validation errors', { status: 422, response: { data: {} } }],
['malformed validation errors', { status: 422, response: { data: { errors: [null] } } }],
])('does not retry a permanent 422 with %s', async (_name, releaseError) => {
const createRelease = vi.fn().mockRejectedValue(releaseError);
const releaser = createReleaser({
getReleaseByTag: vi.fn().mockRejectedValue({ status: 404 }),
createRelease,
allReleases: async function* () {
yield { data: [] };
},
});
await expect(release(config, releaser, 1)).rejects.toBe(releaseError);
expect(createRelease).toHaveBeenCalledOnce();
});
it.each([
['a string', 'transport failed'],
['null', null],
['an arbitrary object', { reason: 'transport failed' }],
['a nested response 500', { response: { status: 500 } }],
])(
'retries unclassified release creation failures without secondary TypeErrors when the error is %s',
async (_name, releaseError) => {
const createRelease = vi.fn().mockRejectedValue(releaseError);
const releaser = createReleaser({
getReleaseByTag: vi.fn().mockRejectedValue({ status: 404 }),
createRelease,
allReleases: async function* () {
yield { data: [] };
},
});
await expect(release(config, releaser, 1)).rejects.toThrow('Too many retries.');
expect(createRelease).toHaveBeenCalledOnce();
},
);
it('passes previous_tag_name through when creating a release with generated notes', async () => { it('passes previous_tag_name through when creating a release with generated notes', async () => {
const createReleaseSpy = vi.fn(async () => ({ const createReleaseSpy = vi.fn(async () => ({
data: { data: {
@@ -1118,6 +1417,99 @@ describe('github', () => {
expect(createRelease).not.toHaveBeenCalled(); expect(createRelease).not.toHaveBeenCalled();
}); });
it('creates a replacement after an existing-release update returns a nested response 404', async () => {
const existingRelease: Release = {
id: 41,
upload_url: 'existing-upload',
html_url: 'existing-html',
tag_name: 'v1.0.0',
name: 'existing release',
body: 'existing body',
target_commitish: 'main',
draft: false,
prerelease: false,
assets: [],
};
const replacementRelease: Release = {
...existingRelease,
id: 42,
upload_url: 'replacement-upload',
html_url: 'replacement-html',
name: 'v1.0.0',
body: undefined,
draft: true,
};
const updateError = { response: { status: 404 }, message: 'release disappeared' };
const getReleaseByTag = vi
.fn()
.mockResolvedValueOnce({ data: existingRelease })
.mockResolvedValue({ data: replacementRelease });
const updateRelease = vi.fn().mockRejectedValue(updateError);
const createRelease = vi.fn().mockResolvedValue({ data: replacementRelease });
const releaser = createReleaser({
getReleaseByTag,
updateRelease,
createRelease,
allReleases: async function* () {
yield { data: [replacementRelease] };
},
});
await expect(release(config, releaser, 1)).resolves.toEqual({
release: replacementRelease,
created: true,
});
expect(updateRelease).toHaveBeenCalledOnce();
expect(createRelease).toHaveBeenCalledOnce();
expect(createRelease).toHaveBeenCalledWith({
owner: 'owner',
repo: 'repo',
tag_name: 'v1.0.0',
name: 'v1.0.0',
body: undefined,
draft: true,
prerelease: undefined,
target_commitish: undefined,
discussion_category_name: undefined,
generate_release_notes: false,
make_latest: undefined,
previous_tag_name: undefined,
});
expect(getReleaseByTag).toHaveBeenCalledTimes(2);
});
it.each([
['a nested response 403', { response: { status: 403 }, message: 'forbidden' }],
['a nested response 500', { response: { status: 500 }, message: 'server error' }],
])(
'does not create a replacement after an existing-release update returns %s',
async (_name, updateError) => {
const existingRelease: Release = {
id: 41,
upload_url: 'existing-upload',
html_url: 'existing-html',
tag_name: 'v1.0.0',
name: 'existing release',
body: 'existing body',
target_commitish: 'main',
draft: false,
prerelease: false,
assets: [],
};
const createRelease = vi.fn();
const updateRelease = vi.fn().mockRejectedValue(updateError);
const releaser = createReleaser({
getReleaseByTag: vi.fn().mockResolvedValue({ data: existingRelease }),
updateRelease,
createRelease,
});
await expect(release(config, releaser, 1)).rejects.toBe(updateError);
expect(updateRelease).toHaveBeenCalledOnce();
expect(createRelease).not.toHaveBeenCalled();
},
);
it.each([ it.each([
['an omitted draft input', undefined], ['an omitted draft input', undefined],
['a null-expression draft input', undefined], ['a null-expression draft input', undefined],
@@ -1263,19 +1655,37 @@ describe('github', () => {
expect(uploadReleaseAsset).not.toHaveBeenCalled(); expect(uploadReleaseAsset).not.toHaveBeenCalled();
}); });
it('surfaces an actionable immutable-release error for prerelease uploads', async () => { it.each([
{
name: 'standard release with a nested status',
prerelease: undefined,
uploadError: {
response: { status: 422 },
message: 'Cannot upload assets to an immutable release.',
},
expected:
'Cannot upload asset draft-false.txt to an immutable release. GitHub only allows asset uploads before a release is published, so upload assets to a draft release before you publish it.',
},
{
name: 'prerelease with response data',
prerelease: true,
uploadError: {
status: 422,
response: {
data: {
message: 'Cannot upload assets to an immutable release.',
},
},
},
expected:
'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.',
},
])('surfaces an actionable immutable-release error for a $name', async (testCase) => {
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');
writeFileSync(assetPath, 'hello'); writeFileSync(assetPath, 'hello');
const uploadReleaseAsset = vi.fn().mockRejectedValue({ const uploadReleaseAsset = vi.fn().mockRejectedValue(testCase.uploadError);
status: 422,
response: {
data: {
message: 'Cannot upload assets to an immutable release.',
},
},
});
const mockReleaser: Releaser = { const mockReleaser: Releaser = {
getReleaseByTag: () => Promise.reject('Not implemented'), getReleaseByTag: () => Promise.reject('Not implemented'),
@@ -1292,23 +1702,23 @@ describe('github', () => {
uploadReleaseAsset, uploadReleaseAsset,
}; };
await expect( try {
upload( await expect(
{ upload(
...config, {
input_prerelease: true, ...config,
}, input_prerelease: testCase.prerelease,
mockReleaser, },
'https://uploads.github.com/repos/owner/repo/releases/1/assets', mockReleaser,
assetPath, 'https://uploads.github.com/repos/owner/repo/releases/1/assets',
[], assetPath,
1, [],
), 1,
).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.', ).rejects.toThrow(testCase.expected);
); } finally {
rmSync(tempDir, { recursive: true, force: true });
rmSync(tempDir, { recursive: true, force: true }); }
}); });
it('retries upload after deleting a conflicting renamed asset matched by label', async () => { it('retries upload after deleting a conflicting renamed asset matched by label', async () => {
@@ -1385,7 +1795,24 @@ describe('github', () => {
} }
}); });
it('handles 422 already_exists error gracefully', async () => { it.each([
{
name: 'top-level status',
releaseError: {
status: 422,
response: { data: { errors: [{ code: 'already_exists' }] } },
},
},
{
name: 'nested response status',
releaseError: {
response: {
status: 422,
data: { errors: [{ code: 'already_exists' }] },
},
},
},
])('retries a 422 already_exists race with $name', async (testCase) => {
vi.useFakeTimers(); vi.useFakeTimers();
const existingRelease = { const existingRelease = {
id: 1, id: 1,
@@ -1411,10 +1838,7 @@ describe('github', () => {
}, },
createRelease: () => { createRelease: () => {
createAttempts++; createAttempts++;
return Promise.reject({ return Promise.reject(testCase.releaseError);
status: 422,
response: { data: { errors: [{ code: 'already_exists' }] } },
});
}, },
updateRelease: () => updateRelease: () =>
Promise.resolve({ Promise.resolve({
@@ -1447,6 +1871,7 @@ describe('github', () => {
assert.ok(result); assert.ok(result);
assert.equal(result.release.id, 1); assert.equal(result.release.id, 1);
assert.equal(result.created, false); assert.equal(result.created, false);
expect(createAttempts).toBe(1);
}); });
it('normalizes refs/tags-prefixed input_tag_name values before reusing an existing release', async () => { it('normalizes refs/tags-prefixed input_tag_name values before reusing an existing release', async () => {
+25 -25
View File
File diff suppressed because one or more lines are too long
+69 -39
View File
@@ -9,6 +9,43 @@ type GitHub = InstanceType<typeof GitHub>;
type UploadChunk = ArrayBuffer | Uint8Array<ArrayBufferLike>; type UploadChunk = ArrayBuffer | Uint8Array<ArrayBufferLike>;
type UploadBody = ReadableStream<Uint8Array<ArrayBufferLike>>; type UploadBody = ReadableStream<Uint8Array<ArrayBufferLike>>;
type UnknownRecord = Record<string, unknown>;
const asRecord = (value: unknown): UnknownRecord | undefined =>
typeof value === 'object' && value !== null ? (value as UnknownRecord) : undefined;
const getErrorStatus = (error: unknown): number | undefined => {
const errorRecord = asRecord(error);
if (typeof errorRecord?.status === 'number') {
return errorRecord.status;
}
const response = asRecord(errorRecord?.response);
return typeof response?.status === 'number' ? response.status : undefined;
};
const getResponseData = (error: unknown): UnknownRecord | undefined => {
const response = asRecord(asRecord(error)?.response);
return asRecord(response?.data);
};
const getErrorMessage = (error: unknown): string | undefined => {
const message = asRecord(error)?.message;
return typeof message === 'string' ? message : undefined;
};
const getRequestUrl = (error: unknown): string | undefined => {
const request = asRecord(asRecord(error)?.request);
return typeof request?.url === 'string' ? request.url : undefined;
};
const getValidationErrors = (error: unknown): unknown[] => {
const errors = getResponseData(error)?.errors;
return Array.isArray(errors) ? errors : [];
};
const hasValidationErrorCode = (error: unknown, code: string): boolean =>
asRecord(getValidationErrors(error)[0])?.code === code;
const fileUploadStream = (fileHandle: FileHandle): UploadBody => { const fileUploadStream = (fileHandle: FileHandle): UploadBody => {
const source = fileHandle.readableWebStream() as ReadableStream<UploadChunk>; const source = fileHandle.readableWebStream() as ReadableStream<UploadChunk>;
@@ -278,10 +315,7 @@ export class GitHubReleaser implements Releaser {
try { try {
await this.github.rest.repos.deleteReleaseAsset(githubParams); await this.github.rest.repos.deleteReleaseAsset(githubParams);
} catch (error: unknown) { } catch (error: unknown) {
const status = if (getErrorStatus(error) !== 404) {
(error as { status?: number; response?: { status?: number } })?.status ??
(error as { response?: { status?: number } })?.response?.status;
if (status !== 404) {
throw error; throw error;
} }
@@ -352,10 +386,10 @@ const releaseAssetMatchesName = (
asset: { name: string; label?: string | null }, asset: { name: string; label?: string | null },
): boolean => asset.name === name || asset.name === alignAssetName(name) || asset.label === name; ): boolean => asset.name === name || asset.name === alignAssetName(name) || asset.label === name;
const isReleaseAssetUpdateNotFound = (error: any): boolean => { const isReleaseAssetUpdateNotFound = (error: unknown): boolean => {
const errorStatus = error?.status ?? error?.response?.status; const errorStatus = getErrorStatus(error);
const requestUrl = error?.request?.url; const requestUrl = getRequestUrl(error);
const errorMessage = error?.message; const message = getErrorMessage(error);
const isReleaseAssetRequest = const isReleaseAssetRequest =
typeof requestUrl === 'string' && typeof requestUrl === 'string' &&
(/\/releases\/assets\//.test(requestUrl) || /\/releases\/\d+\/assets(?:\?|$)/.test(requestUrl)); (/\/releases\/assets\//.test(requestUrl) || /\/releases\/\d+\/assets(?:\?|$)/.test(requestUrl));
@@ -363,15 +397,15 @@ const isReleaseAssetUpdateNotFound = (error: any): boolean => {
return ( return (
errorStatus === 404 && errorStatus === 404 &&
(isReleaseAssetRequest || (isReleaseAssetRequest ||
(typeof errorMessage === 'string' && errorMessage.includes('update-a-release-asset'))) (typeof message === 'string' && message.includes('update-a-release-asset')))
); );
}; };
const isImmutableReleaseAssetUploadFailure = (error: any): boolean => { const isImmutableReleaseAssetUploadFailure = (error: unknown): boolean => {
const errorStatus = error?.status ?? error?.response?.status; const errorStatus = getErrorStatus(error);
const errorMessage = error?.response?.data?.message ?? error?.message; const message = getResponseData(error)?.message ?? getErrorMessage(error);
return errorStatus === 422 && /immutable release/i.test(String(errorMessage)); return errorStatus === 422 && /immutable release/i.test(String(message));
}; };
const immutableReleaseAssetUploadMessage = ( const immutableReleaseAssetUploadMessage = (
@@ -480,8 +514,8 @@ export const upload = async (
try { try {
return await updateAssetLabel(uploadedAsset.id); return await updateAssetLabel(uploadedAsset.id);
} catch (error: any) { } catch (error: unknown) {
const errorStatus = error?.status ?? error?.response?.status; const errorStatus = getErrorStatus(error);
if (errorStatus === 404 && releaseId !== undefined) { if (errorStatus === 404 && releaseId !== undefined) {
try { try {
@@ -518,9 +552,8 @@ export const upload = async (
try { try {
return await handleUploadedAsset(await uploadAsset()); return await handleUploadedAsset(await uploadAsset());
} catch (error: any) { } catch (error: unknown) {
const errorStatus = error?.status ?? error?.response?.status; const errorStatus = getErrorStatus(error);
const errorData = error?.response?.data;
if (isImmutableReleaseAssetUploadFailure(error)) { if (isImmutableReleaseAssetUploadFailure(error)) {
throw new Error(immutableReleaseAssetUploadMessage(name, config.input_prerelease)); throw new Error(immutableReleaseAssetUploadMessage(name, config.input_prerelease));
@@ -548,7 +581,7 @@ export const upload = async (
if ( if (
config.input_overwrite_files !== false && config.input_overwrite_files !== false &&
errorStatus === 422 && errorStatus === 422 &&
errorData?.errors?.[0]?.code === 'already_exists' && hasValidationErrorCode(error, 'already_exists') &&
releaseId !== undefined releaseId !== undefined
) { ) {
console.log( console.log(
@@ -603,7 +636,7 @@ 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) { if (getErrorStatus(error) === 404) {
const diagnostic = releaseLookup404Message(owner, repo, error); const diagnostic = releaseLookup404Message(owner, repo, error);
console.log(`⚠️ ${diagnostic}`); console.log(`⚠️ ${diagnostic}`);
throw new ReleaseAccessError(diagnostic, error); throw new ReleaseAccessError(diagnostic, error);
@@ -689,7 +722,7 @@ export const release = async (
if (error instanceof ReleaseCreationError) { if (error instanceof ReleaseCreationError) {
throw error; throw error;
} }
if (error.status !== 404) { if (getErrorStatus(error) !== 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}`,
); );
@@ -840,7 +873,7 @@ export async function findTagFromReleases(
const { data: release } = await releaser.getReleaseByTag({ owner, repo, tag }); const { data: release } = await releaser.getReleaseByTag({ owner, repo, tag });
return release; return release;
} catch (error) { } catch (error) {
if (error.status !== 404) { if (getErrorStatus(error) !== 404) {
throw error; throw error;
} }
} }
@@ -1058,15 +1091,12 @@ async function createRelease(
created: canonicalRelease.id === createdRelease.data.id, created: canonicalRelease.id === createdRelease.data.id,
}; };
} catch (error: unknown) { } catch (error: unknown) {
const githubError = error as { const errorStatus = getErrorStatus(error);
status?: number;
response?: { data?: { errors?: Array<{ code?: string }> } };
};
// presume a race with competing matrix runs // presume a race with competing matrix runs
console.log(`⚠️ GitHub release failed with status: ${githubError.status}`); console.log(`⚠️ GitHub release failed with status: ${errorStatus}`);
console.log(errorMessage(error)); console.log(errorMessage(error));
switch (githubError.status) { switch (errorStatus) {
case 403: case 403:
console.log( console.log(
'Skip retry — your GitHub token/PAT does not have the required permission to create a release', 'Skip retry — your GitHub token/PAT does not have the required permission to create a release',
@@ -1080,8 +1110,7 @@ async function createRelease(
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
const errorData = githubError.response?.data; if (hasValidationErrorCode(error, 'already_exists')) {
if (errorData?.errors?.[0]?.code === 'already_exists') {
console.log( console.log(
'⚠️ Release already exists (race condition detected), retrying to find and update existing release...', '⚠️ Release already exists (race condition detected), retrying to find and update existing release...',
); );
@@ -1098,16 +1127,17 @@ async function createRelease(
} }
} }
function isTagCreationBlockedError(error: any): boolean { function isTagCreationBlockedError(error: unknown): boolean {
const errors = error?.response?.data?.errors; if (getErrorStatus(error) !== 422) {
if (!Array.isArray(errors) || error?.status !== 422) {
return false; return false;
} }
return errors.some( return getValidationErrors(error).some((validationError) => {
({ field, message }: { field?: string; message?: string }) => const errorRecord = asRecord(validationError);
field === 'pre_receive' && return (
typeof message === 'string' && errorRecord?.field === 'pre_receive' &&
message.includes('creations being restricted'), typeof errorRecord.message === 'string' &&
); errorRecord.message.includes('creations being restricted')
);
});
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"compilerOptions": { "compilerOptions": {
"useUnknownInCatchVariables": false, "useUnknownInCatchVariables": true,
/* Basic Options */ /* Basic Options */
// "incremental": true, /* Enable incremental compilation */ // "incremental": true, /* Enable incremental compilation */
"target": "es2022", "target": "es2022",
+4 -4
View File
@@ -7,10 +7,10 @@ export default defineConfig({
reporter: ['text', 'json-summary', 'lcov'], reporter: ['text', 'json-summary', 'lcov'],
include: ['src/**/*.ts'], include: ['src/**/*.ts'],
thresholds: { thresholds: {
statements: 88, statements: 94,
branches: 83, branches: 90,
functions: 86, functions: 95,
lines: 88, lines: 94,
}, },
}, },
include: ['__tests__/**/*.ts'], include: ['__tests__/**/*.ts'],