fix: clean up orphan drafts when tag creation is blocked (#750)

Signed-off-by: Rui Chen <rui@chenrui.dev>
This commit is contained in:
Rui Chen
2026-03-14 21:51:04 -04:00
committed by GitHub
parent 52847653ee
commit 488ac715ff
4 changed files with 206 additions and 40 deletions

View File

@@ -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', () => {
@@ -270,7 +377,8 @@ describe('github', () => {
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.objectContaining({
draft: false,
@@ -386,7 +494,8 @@ describe('github', () => {
const result = await release(config, mockReleaser, 2);
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 () => {
@@ -440,7 +549,8 @@ describe('github', () => {
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({
owner: 'owner',
repo: 'repo',
@@ -492,7 +602,8 @@ describe('github', () => {
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({
owner: 'owner',
repo: 'repo',

54
dist/index.js vendored

File diff suppressed because one or more lines are too long

View File

@@ -26,6 +26,11 @@ export interface Release {
assets: Array<{ id: number; name: string; label?: string | null }>;
}
export interface ReleaseResult {
release: Release;
created: boolean;
}
export interface Releaser {
getReleaseByTag(params: { owner: string; repo: string; tag: string }): Promise<{ data: Release }>;
@@ -407,7 +412,7 @@ export const release = async (
config: Config,
releaser: Releaser,
maxRetries: number = 3,
): Promise<Release> => {
): Promise<ReleaseResult> => {
if (maxRetries <= 0) {
console.log(`❌ Too many retries. Aborting...`);
throw new Error('Too many retries.');
@@ -487,7 +492,10 @@ export const release = async (
generate_release_notes,
make_latest,
});
return release.data;
return {
release: release.data,
created: false,
};
} catch (error) {
if (error.status !== 404) {
console.log(
@@ -522,6 +530,7 @@ export const finalizeRelease = async (
config: Config,
releaser: Releaser,
release: Release,
releaseWasCreated: boolean = false,
maxRetries: number = 3,
): Promise<Release> => {
if (config.input_draft === true || release.draft === false) {
@@ -545,8 +554,34 @@ export const finalizeRelease = async (
return data;
} catch (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)`);
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,
generate_release_notes: boolean | undefined,
maxRetries: number,
) {
): Promise<ReleaseResult> {
const tag_name = tag;
const name = config.input_name || tag;
const body = releaseBody(config);
@@ -775,7 +810,7 @@ async function createRelease(
}
console.log(`👩‍🏭 Creating new GitHub release for tag ${tag_name}${commitMessage}...`);
try {
const release = await releaser.createRelease({
const createdRelease = await releaser.createRelease({
owner,
repo,
tag_name,
@@ -788,14 +823,18 @@ async function createRelease(
generate_release_notes,
make_latest,
});
return await canonicalizeCreatedRelease(
const canonicalRelease = await canonicalizeCreatedRelease(
releaser,
owner,
repo,
tag_name,
release.data,
createdRelease.data,
maxRetries,
);
return {
release: canonicalRelease,
created: canonicalRelease.id === createdRelease.data.id,
};
} catch (error) {
// presume a race with competing matrix runs
console.log(`⚠️ GitHub release failed with status: ${error.status}`);
@@ -831,3 +870,17 @@ async function createRelease(
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'),
);
}

View File

@@ -49,7 +49,9 @@ async function run() {
});
//);
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();
if (config.input_files && config.input_files.length > 0) {
const files = paths(config.input_files, config.input_working_directory);
@@ -81,7 +83,7 @@ async function run() {
}
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.
// URLs will be changed to correct ones once the release is published.