mirror of
https://github.com/softprops/action-gh-release.git
synced 2026-03-18 12:19:01 +08:00
fix: clean up orphan drafts when tag creation is blocked (#750)
Signed-off-by: Rui Chen <rui@chenrui.dev>
This commit is contained in:
@@ -230,6 +230,113 @@ describe('github', () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('deletes a newly created draft when tag creation is blocked by repository rules', async () => {
|
||||||
|
const finalizeReleaseSpy = vi.fn(async () => {
|
||||||
|
throw {
|
||||||
|
status: 422,
|
||||||
|
response: {
|
||||||
|
data: {
|
||||||
|
errors: [
|
||||||
|
{
|
||||||
|
field: 'pre_receive',
|
||||||
|
message:
|
||||||
|
'pre_receive Repository rule violations found\n\nCannot create ref due to creations being restricted.\n\n',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const deleteReleaseSpy = vi.fn(async () => undefined);
|
||||||
|
|
||||||
|
const releaser: Releaser = {
|
||||||
|
getReleaseByTag: () => Promise.reject('Not implemented'),
|
||||||
|
createRelease: () => Promise.reject('Not implemented'),
|
||||||
|
updateRelease: () => Promise.reject('Not implemented'),
|
||||||
|
finalizeRelease: finalizeReleaseSpy,
|
||||||
|
allReleases: async function* () {
|
||||||
|
throw new Error('Not implemented');
|
||||||
|
},
|
||||||
|
listReleaseAssets: () => Promise.reject('Not implemented'),
|
||||||
|
deleteReleaseAsset: () => Promise.reject('Not implemented'),
|
||||||
|
deleteRelease: deleteReleaseSpy,
|
||||||
|
updateReleaseAsset: () => Promise.reject('Not implemented'),
|
||||||
|
uploadReleaseAsset: () => Promise.reject('Not implemented'),
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
finalizeRelease(
|
||||||
|
{
|
||||||
|
...config,
|
||||||
|
input_draft: false,
|
||||||
|
},
|
||||||
|
releaser,
|
||||||
|
draftRelease,
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
).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).toHaveBeenCalledTimes(1);
|
||||||
|
expect(deleteReleaseSpy).toHaveBeenCalledWith({
|
||||||
|
owner: 'owner',
|
||||||
|
repo: 'repo',
|
||||||
|
release_id: draftRelease.id,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not delete an existing draft release when tag creation is blocked by repository rules', async () => {
|
||||||
|
const finalizeReleaseSpy = vi.fn(async () => {
|
||||||
|
throw {
|
||||||
|
status: 422,
|
||||||
|
response: {
|
||||||
|
data: {
|
||||||
|
errors: [
|
||||||
|
{
|
||||||
|
field: 'pre_receive',
|
||||||
|
message:
|
||||||
|
'pre_receive Repository rule violations found\n\nCannot create ref due to creations being restricted.\n\n',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const deleteReleaseSpy = vi.fn(async () => undefined);
|
||||||
|
|
||||||
|
const releaser: Releaser = {
|
||||||
|
getReleaseByTag: () => Promise.reject('Not implemented'),
|
||||||
|
createRelease: () => Promise.reject('Not implemented'),
|
||||||
|
updateRelease: () => Promise.reject('Not implemented'),
|
||||||
|
finalizeRelease: finalizeReleaseSpy,
|
||||||
|
allReleases: async function* () {
|
||||||
|
throw new Error('Not implemented');
|
||||||
|
},
|
||||||
|
listReleaseAssets: () => Promise.reject('Not implemented'),
|
||||||
|
deleteReleaseAsset: () => Promise.reject('Not implemented'),
|
||||||
|
deleteRelease: deleteReleaseSpy,
|
||||||
|
updateReleaseAsset: () => Promise.reject('Not implemented'),
|
||||||
|
uploadReleaseAsset: () => Promise.reject('Not implemented'),
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
finalizeRelease(
|
||||||
|
{
|
||||||
|
...config,
|
||||||
|
input_draft: false,
|
||||||
|
},
|
||||||
|
releaser,
|
||||||
|
draftRelease,
|
||||||
|
false,
|
||||||
|
1,
|
||||||
|
),
|
||||||
|
).rejects.toThrow('Too many retries.');
|
||||||
|
|
||||||
|
expect(finalizeReleaseSpy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(deleteReleaseSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('error handling', () => {
|
describe('error handling', () => {
|
||||||
@@ -270,7 +377,8 @@ describe('github', () => {
|
|||||||
|
|
||||||
const result = await release(prereleaseConfig, mockReleaser, 1);
|
const result = await release(prereleaseConfig, mockReleaser, 1);
|
||||||
|
|
||||||
assert.equal(result.id, createdRelease.id);
|
assert.equal(result.release.id, createdRelease.id);
|
||||||
|
assert.equal(result.created, true);
|
||||||
expect(createReleaseSpy).toHaveBeenCalledWith(
|
expect(createReleaseSpy).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
draft: false,
|
draft: false,
|
||||||
@@ -386,7 +494,8 @@ describe('github', () => {
|
|||||||
|
|
||||||
const result = await release(config, mockReleaser, 2);
|
const result = await release(config, mockReleaser, 2);
|
||||||
assert.ok(result);
|
assert.ok(result);
|
||||||
assert.equal(result.id, 1);
|
assert.equal(result.release.id, 1);
|
||||||
|
assert.equal(result.created, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('reuses a canonical release after concurrent create success and removes empty duplicates', async () => {
|
it('reuses a canonical release after concurrent create success and removes empty duplicates', async () => {
|
||||||
@@ -440,7 +549,8 @@ describe('github', () => {
|
|||||||
|
|
||||||
const result = await release(config, mockReleaser, 2);
|
const result = await release(config, mockReleaser, 2);
|
||||||
|
|
||||||
assert.equal(result.id, canonicalRelease.id);
|
assert.equal(result.release.id, canonicalRelease.id);
|
||||||
|
assert.equal(result.created, false);
|
||||||
expect(deleteReleaseSpy).toHaveBeenCalledWith({
|
expect(deleteReleaseSpy).toHaveBeenCalledWith({
|
||||||
owner: 'owner',
|
owner: 'owner',
|
||||||
repo: 'repo',
|
repo: 'repo',
|
||||||
@@ -492,7 +602,8 @@ describe('github', () => {
|
|||||||
|
|
||||||
const result = await release(config, mockReleaser, 1);
|
const result = await release(config, mockReleaser, 1);
|
||||||
|
|
||||||
assert.equal(result.id, canonicalRelease.id);
|
assert.equal(result.release.id, canonicalRelease.id);
|
||||||
|
assert.equal(result.created, false);
|
||||||
expect(deleteReleaseSpy).toHaveBeenCalledWith({
|
expect(deleteReleaseSpy).toHaveBeenCalledWith({
|
||||||
owner: 'owner',
|
owner: 'owner',
|
||||||
repo: 'repo',
|
repo: 'repo',
|
||||||
|
|||||||
54
dist/index.js
vendored
54
dist/index.js
vendored
File diff suppressed because one or more lines are too long
@@ -26,6 +26,11 @@ export interface Release {
|
|||||||
assets: Array<{ id: number; name: string; label?: string | null }>;
|
assets: Array<{ id: number; name: string; label?: string | null }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ReleaseResult {
|
||||||
|
release: Release;
|
||||||
|
created: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
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 }>;
|
||||||
|
|
||||||
@@ -407,7 +412,7 @@ export const release = async (
|
|||||||
config: Config,
|
config: Config,
|
||||||
releaser: Releaser,
|
releaser: Releaser,
|
||||||
maxRetries: number = 3,
|
maxRetries: number = 3,
|
||||||
): Promise<Release> => {
|
): Promise<ReleaseResult> => {
|
||||||
if (maxRetries <= 0) {
|
if (maxRetries <= 0) {
|
||||||
console.log(`❌ Too many retries. Aborting...`);
|
console.log(`❌ Too many retries. Aborting...`);
|
||||||
throw new Error('Too many retries.');
|
throw new Error('Too many retries.');
|
||||||
@@ -487,7 +492,10 @@ export const release = async (
|
|||||||
generate_release_notes,
|
generate_release_notes,
|
||||||
make_latest,
|
make_latest,
|
||||||
});
|
});
|
||||||
return release.data;
|
return {
|
||||||
|
release: release.data,
|
||||||
|
created: false,
|
||||||
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.status !== 404) {
|
if (error.status !== 404) {
|
||||||
console.log(
|
console.log(
|
||||||
@@ -522,6 +530,7 @@ export const finalizeRelease = async (
|
|||||||
config: Config,
|
config: Config,
|
||||||
releaser: Releaser,
|
releaser: Releaser,
|
||||||
release: Release,
|
release: Release,
|
||||||
|
releaseWasCreated: boolean = false,
|
||||||
maxRetries: number = 3,
|
maxRetries: number = 3,
|
||||||
): Promise<Release> => {
|
): Promise<Release> => {
|
||||||
if (config.input_draft === true || release.draft === false) {
|
if (config.input_draft === true || release.draft === false) {
|
||||||
@@ -545,8 +554,34 @@ export const finalizeRelease = async (
|
|||||||
return data;
|
return data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(`error finalizing release: ${error}`);
|
console.warn(`error finalizing release: ${error}`);
|
||||||
|
|
||||||
|
if (releaseWasCreated && release.draft && isTagCreationBlockedError(error)) {
|
||||||
|
let deleted = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log(
|
||||||
|
`🧹 Deleting draft release ${release.id} for tag ${release.tag_name} because tag creation is blocked by repository rules...`,
|
||||||
|
);
|
||||||
|
await releaser.deleteRelease({
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
release_id: release.id,
|
||||||
|
});
|
||||||
|
deleted = true;
|
||||||
|
} catch (cleanupError) {
|
||||||
|
console.warn(`error deleting orphan draft release ${release.id}: ${cleanupError}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanupResult = deleted
|
||||||
|
? `Deleted draft release ${release.id} to avoid leaving an orphaned draft release.`
|
||||||
|
: `Failed to delete draft release ${release.id}; manual cleanup may still be required.`;
|
||||||
|
throw new Error(
|
||||||
|
`Tag creation for ${release.tag_name} is blocked by repository rules. ${cleanupResult}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
console.log(`retrying... (${maxRetries - 1} retries remaining)`);
|
console.log(`retrying... (${maxRetries - 1} retries remaining)`);
|
||||||
return finalizeRelease(config, releaser, release, maxRetries - 1);
|
return finalizeRelease(config, releaser, release, releaseWasCreated, maxRetries - 1);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -761,7 +796,7 @@ async function createRelease(
|
|||||||
discussion_category_name: string | undefined,
|
discussion_category_name: string | undefined,
|
||||||
generate_release_notes: boolean | undefined,
|
generate_release_notes: boolean | undefined,
|
||||||
maxRetries: number,
|
maxRetries: number,
|
||||||
) {
|
): Promise<ReleaseResult> {
|
||||||
const tag_name = tag;
|
const tag_name = tag;
|
||||||
const name = config.input_name || tag;
|
const name = config.input_name || tag;
|
||||||
const body = releaseBody(config);
|
const body = releaseBody(config);
|
||||||
@@ -775,7 +810,7 @@ async function createRelease(
|
|||||||
}
|
}
|
||||||
console.log(`👩🏭 Creating new GitHub release for tag ${tag_name}${commitMessage}...`);
|
console.log(`👩🏭 Creating new GitHub release for tag ${tag_name}${commitMessage}...`);
|
||||||
try {
|
try {
|
||||||
const release = await releaser.createRelease({
|
const createdRelease = await releaser.createRelease({
|
||||||
owner,
|
owner,
|
||||||
repo,
|
repo,
|
||||||
tag_name,
|
tag_name,
|
||||||
@@ -788,14 +823,18 @@ async function createRelease(
|
|||||||
generate_release_notes,
|
generate_release_notes,
|
||||||
make_latest,
|
make_latest,
|
||||||
});
|
});
|
||||||
return await canonicalizeCreatedRelease(
|
const canonicalRelease = await canonicalizeCreatedRelease(
|
||||||
releaser,
|
releaser,
|
||||||
owner,
|
owner,
|
||||||
repo,
|
repo,
|
||||||
tag_name,
|
tag_name,
|
||||||
release.data,
|
createdRelease.data,
|
||||||
maxRetries,
|
maxRetries,
|
||||||
);
|
);
|
||||||
|
return {
|
||||||
|
release: canonicalRelease,
|
||||||
|
created: canonicalRelease.id === createdRelease.data.id,
|
||||||
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// presume a race with competing matrix runs
|
// presume a race with competing matrix runs
|
||||||
console.log(`⚠️ GitHub release failed with status: ${error.status}`);
|
console.log(`⚠️ GitHub release failed with status: ${error.status}`);
|
||||||
@@ -831,3 +870,17 @@ async function createRelease(
|
|||||||
return release(config, releaser, maxRetries - 1);
|
return release(config, releaser, maxRetries - 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isTagCreationBlockedError(error: any): boolean {
|
||||||
|
const errors = error?.response?.data?.errors;
|
||||||
|
if (!Array.isArray(errors) || error?.status !== 422) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors.some(
|
||||||
|
({ field, message }: { field?: string; message?: string }) =>
|
||||||
|
field === 'pre_receive' &&
|
||||||
|
typeof message === 'string' &&
|
||||||
|
message.includes('creations being restricted'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -49,7 +49,9 @@ async function run() {
|
|||||||
});
|
});
|
||||||
//);
|
//);
|
||||||
const releaser = new GitHubReleaser(gh);
|
const releaser = new GitHubReleaser(gh);
|
||||||
let rel = await release(config, releaser);
|
const releaseResult = await release(config, releaser);
|
||||||
|
let rel = releaseResult.release;
|
||||||
|
const releaseWasCreated = releaseResult.created;
|
||||||
let uploadedAssetIds: Set<number> = new Set();
|
let uploadedAssetIds: Set<number> = new Set();
|
||||||
if (config.input_files && config.input_files.length > 0) {
|
if (config.input_files && config.input_files.length > 0) {
|
||||||
const files = paths(config.input_files, config.input_working_directory);
|
const files = paths(config.input_files, config.input_working_directory);
|
||||||
@@ -81,7 +83,7 @@ async function run() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
console.log('Finalizing release...');
|
console.log('Finalizing release...');
|
||||||
rel = await finalizeRelease(config, releaser, rel);
|
rel = await finalizeRelease(config, releaser, rel, releaseWasCreated);
|
||||||
|
|
||||||
// Draft releases use temporary "untagged-..." URLs for assets.
|
// Draft releases use temporary "untagged-..." URLs for assets.
|
||||||
// URLs will be changed to correct ones once the release is published.
|
// URLs will be changed to correct ones once the release is published.
|
||||||
|
|||||||
Reference in New Issue
Block a user