diff --git a/__tests__/github.test.ts b/__tests__/github.test.ts index b1c0b0a..5a5adb6 100644 --- a/__tests__/github.test.ts +++ b/__tests__/github.test.ts @@ -68,7 +68,7 @@ describe('github', () => { } as const; const mockReleaser: Releaser = { - getReleaseByTag: () => Promise.reject('Not implemented'), + getReleaseByTag: () => Promise.reject({ status: 404 }), createRelease: () => Promise.reject('Not implemented'), updateRelease: () => Promise.reject('Not implemented'), finalizeRelease: () => Promise.reject('Not implemented'), @@ -80,184 +80,63 @@ describe('github', () => { uploadReleaseAsset: () => Promise.reject('Not implemented'), } as const; - describe('when the tag_name is not an empty string', () => { + it('finds a release by tag using direct API lookup', async () => { const targetTag = 'v1.0.0'; + const targetRelease = { + ...mockRelease, + tag_name: targetTag, + }; - it('finds a matching release in first batch of results', async () => { - const targetRelease = { - ...mockRelease, - owner, - repo, - tag_name: targetTag, - }; - const otherRelease = { - ...mockRelease, - owner, - repo, - tag_name: 'v1.0.1', - }; + const releaser = { + ...mockReleaser, + getReleaseByTag: () => Promise.resolve({ data: targetRelease }), + }; - const releaser = { - ...mockReleaser, - allReleases: async function* () { - yield { data: [targetRelease] }; - yield { data: [otherRelease] }; - }, - }; + const result = await findTagFromReleases(releaser, owner, repo, targetTag); - const result = await findTagFromReleases(releaser, owner, repo, targetTag); - - assert.deepStrictEqual(result, targetRelease); - }); - - it('finds a matching release in second batch of results', async () => { - const targetRelease = { - ...mockRelease, - owner, - repo, - tag_name: targetTag, - }; - const otherRelease = { - ...mockRelease, - owner, - repo, - tag_name: 'v1.0.1', - }; - - const releaser = { - ...mockReleaser, - allReleases: async function* () { - yield { data: [otherRelease] }; - yield { data: [targetRelease] }; - }, - }; - - const result = await findTagFromReleases(releaser, owner, repo, targetTag); - assert.deepStrictEqual(result, targetRelease); - }); - - it('returns undefined when a release is not found in any batch', async () => { - const otherRelease = { - ...mockRelease, - owner, - repo, - tag_name: 'v1.0.1', - }; - const releaser = { - ...mockReleaser, - allReleases: async function* () { - yield { data: [otherRelease] }; - yield { data: [otherRelease] }; - }, - }; - - const result = await findTagFromReleases(releaser, owner, repo, targetTag); - - assert.strictEqual(result, undefined); - }); - - it('returns undefined when no releases are returned', async () => { - const releaser = { - ...mockReleaser, - allReleases: async function* () { - yield { data: [] }; - }, - }; - - const result = await findTagFromReleases(releaser, owner, repo, targetTag); - - assert.strictEqual(result, undefined); - }); + assert.deepStrictEqual(result, targetRelease); }); - describe('when the tag_name is an empty string', () => { + it('returns undefined when release is not found (404)', async () => { + const releaser = { + ...mockReleaser, + getReleaseByTag: () => Promise.reject({ status: 404 }), + }; + + const result = await findTagFromReleases(releaser, owner, repo, 'nonexistent'); + + assert.strictEqual(result, undefined); + }); + + it('re-throws non-404 errors', async () => { + const releaser = { + ...mockReleaser, + getReleaseByTag: () => Promise.reject({ status: 500, message: 'Server error' }), + }; + + try { + await findTagFromReleases(releaser, owner, repo, 'v1.0.0'); + assert.fail('Expected an error to be thrown'); + } catch (error) { + assert.strictEqual(error.status, 500); + } + }); + + it('finds a release with empty tag name', async () => { const emptyTag = ''; + const targetRelease = { + ...mockRelease, + tag_name: emptyTag, + }; - it('finds a matching release in first batch of results', async () => { - const targetRelease = { - ...mockRelease, - owner, - repo, - tag_name: emptyTag, - }; - const otherRelease = { - ...mockRelease, - owner, - repo, - tag_name: 'v1.0.1', - }; + const releaser = { + ...mockReleaser, + getReleaseByTag: () => Promise.resolve({ data: targetRelease }), + }; - const releaser = { - ...mockReleaser, - allReleases: async function* () { - yield { data: [targetRelease] }; - yield { data: [otherRelease] }; - }, - }; + const result = await findTagFromReleases(releaser, owner, repo, emptyTag); - const result = await findTagFromReleases(releaser, owner, repo, emptyTag); - - assert.deepStrictEqual(result, targetRelease); - }); - - it('finds a matching release in second batch of results', async () => { - const targetRelease = { - ...mockRelease, - owner, - repo, - tag_name: emptyTag, - }; - const otherRelease = { - ...mockRelease, - owner, - repo, - tag_name: 'v1.0.1', - }; - - const releaser = { - ...mockReleaser, - allReleases: async function* () { - yield { data: [otherRelease] }; - yield { data: [targetRelease] }; - }, - }; - - const result = await findTagFromReleases(releaser, owner, repo, emptyTag); - assert.deepStrictEqual(result, targetRelease); - }); - - it('returns undefined when a release is not found in any batch', async () => { - const otherRelease = { - ...mockRelease, - owner, - repo, - tag_name: 'v1.0.1', - }; - const releaser = { - ...mockReleaser, - allReleases: async function* () { - yield { data: [otherRelease] }; - yield { data: [otherRelease] }; - }, - }; - - const result = await findTagFromReleases(releaser, owner, repo, emptyTag); - - assert.strictEqual(result, undefined); - }); - - it('returns undefined when no releases are returned', async () => { - const releaser = { - ...mockReleaser, - allReleases: async function* () { - yield { data: [] }; - }, - }; - - const result = await findTagFromReleases(releaser, owner, repo, emptyTag); - - assert.strictEqual(result, undefined); - }); + assert.deepStrictEqual(result, targetRelease); }); }); @@ -333,13 +212,35 @@ describe('github', () => { describe('error handling', () => { it('handles 422 already_exists error gracefully', async () => { + const existingRelease = { + id: 1, + upload_url: 'test', + html_url: 'test', + tag_name: 'v1.0.0', + name: 'test', + body: 'test', + target_commitish: 'main', + draft: false, + prerelease: false, + assets: [], + }; + + let createAttempts = 0; const mockReleaser: Releaser = { - getReleaseByTag: () => Promise.reject('Not implemented'), - createRelease: () => - Promise.reject({ + getReleaseByTag: ({ tag }) => { + // First call returns 404 (release doesn't exist yet), subsequent calls find it + if (createAttempts === 0) { + return Promise.reject({ status: 404 }); + } + return Promise.resolve({ data: existingRelease }); + }, + createRelease: () => { + createAttempts++; + return Promise.reject({ status: 422, response: { data: { errors: [{ code: 'already_exists' }] } }, - }), + }); + }, updateRelease: () => Promise.resolve({ data: { @@ -357,29 +258,14 @@ describe('github', () => { }), finalizeRelease: () => Promise.reject('Not implemented'), allReleases: async function* () { - yield { - data: [ - { - id: 1, - upload_url: 'test', - html_url: 'test', - tag_name: 'v1.0.0', - name: 'test', - body: 'test', - target_commitish: 'main', - draft: false, - prerelease: false, - assets: [], - }, - ], - }; + yield { data: [existingRelease] }; }, listReleaseAssets: () => Promise.reject('Not implemented'), deleteReleaseAsset: () => Promise.reject('Not implemented'), uploadReleaseAsset: () => Promise.reject('Not implemented'), } as const; - const result = await release(config, mockReleaser, 1); + const result = await release(config, mockReleaser, 2); assert.ok(result); assert.equal(result.id, 1); }); diff --git a/dist/index.js b/dist/index.js index 9e8364b..06ed9bf 100644 --- a/dist/index.js +++ b/dist/index.js @@ -54,7 +54,7 @@ ${s.data.body}`:t.body=s.data.body}return t.body=t.body?this.truncateReleaseNote ${s.data.body}`:t.body=s.data.body}return t.body=t.body?this.truncateReleaseNotes(t.body):void 0,this.github.rest.repos.updateRelease(t)}async finalizeRelease(t){return await this.github.rest.repos.updateRelease({owner:t.owner,repo:t.repo,release_id:t.release_id,draft:!1,make_latest:t.make_latest})}allReleases(t){let s={per_page:100,...t};return this.github.paginate.iterator(this.github.rest.repos.listReleases.endpoint.merge(s))}async listReleaseAssets(t){return this.github.paginate(this.github.rest.repos.listReleaseAssets,{...t,per_page:100})}async deleteReleaseAsset(t){await this.github.rest.repos.deleteReleaseAsset(t)}async uploadReleaseAsset(t){return this.github.request({method:"POST",url:t.url,headers:{"content-length":`${t.size}`,"content-type":t.mime,authorization:`token ${t.token}`},data:t.data})}},v_=e=>({name:(0,xw.basename)(e),mime:k_(e),size:(0,ww.statSync)(e).size}),k_=e=>(0,yw.lookup)(e)||"application/octet-stream",vw=async(e,t,s,r,i)=>{let[o,n]=e.github_repository.split("/"),{name:a,mime:A,size:c}=v_(r),u=i.find(({name:g})=>g==Cw(a));if(u){if(e.input_overwrite_files===!1)return console.log(`Asset ${a} already exists and overwrite_files is false...`),null;console.log(`\u267B\uFE0F Deleting previously uploaded asset ${a}...`),await t.deleteReleaseAsset({asset_id:u.id||1,owner:o,repo:n})}console.log(`\u2B06\uFE0F Uploading ${a}...`);let l=new URL(s);l.searchParams.append("name",a);let p=await(0,bw.open)(r);try{let g=await t.uploadReleaseAsset({url:l.toString(),size:c,mime:A,token:e.github_token,data:p.readableWebStream({type:"bytes"})}),h=g.data;if(g.status!==201)throw new Error(`Failed to upload release asset ${a}. received status code ${g.status} ${h.message} ${JSON.stringify(h.errors)}`);return console.log(`\u2705 Uploaded ${a}`),h}finally{await p.close()}},ap=async(e,t,s=3)=>{if(s<=0)throw console.log("\u274C Too many retries. Aborting..."),new Error("Too many retries.");let[r,i]=e.github_repository.split("/"),o=e.input_tag_name||(ja(e.github_ref)?e.github_ref.replace("refs/tags/",""):""),n=e.input_discussion_category_name,a=e.input_generate_release_notes;try{let A=await D_(t,r,i,o);if(A===void 0)return await Iw(o,e,t,r,i,n,a,s);let c=A;console.log(`Found release ${c.name} (with id=${c.id})`);let u=c.id,l;e.input_target_commitish&&e.input_target_commitish!==c.target_commitish?(console.log(`Updating commit from "${c.target_commitish}" to "${e.input_target_commitish}"`),l=e.input_target_commitish):l=c.target_commitish;let p=o,g=e.input_name||c.name||o,h=np(e)||"",d=c.body||"",m;e.input_append_body&&h&&d?m=d+` -`+h:m=h||d;let E=e.input_prerelease!==void 0?e.input_prerelease:c.prerelease,f=e.input_make_latest;return(await t.updateRelease({owner:r,repo:i,release_id:u,tag_name:p,target_commitish:l,name:g,body:m,draft:c.draft,prerelease:E,discussion_category_name:n,generate_release_notes:a,make_latest:f})).data}catch(A){if(A.status!==404)throw console.log(`\u26A0\uFE0F Unexpected error fetching GitHub release for tag ${e.github_ref}: ${A}`),A;return await Iw(o,e,t,r,i,n,a,s)}},Ap=async(e,t,s,r=3)=>{if(e.input_draft===!0)return s;if(r<=0)throw console.log("\u274C Too many retries. Aborting..."),new Error("Too many retries.");let[i,o]=e.github_repository.split("/");try{let{data:n}=await t.finalizeRelease({owner:i,repo:o,release_id:s.id,make_latest:e.input_make_latest});return n}catch(n){return console.warn(`error finalizing release: ${n}`),console.log(`retrying... (${r-1} retries remaining)`),Ap(e,t,s,r-1)}},cp=async(e,t,s,r=3)=>{if(r<=0)throw console.log("\u274C Too many retries. Aborting..."),new Error("Too many retries.");let[i,o]=e.github_repository.split("/");try{return await t.listReleaseAssets({owner:i,repo:o,release_id:s.id})}catch(n){return console.warn(`error listing assets of release: ${n}`),console.log(`retrying... (${r-1} retries remaining)`),cp(e,t,s,r-1)}};async function D_(e,t,s,r){for await(let{data:i}of e.allReleases({owner:t,repo:s})){let o=i.find(n=>n.tag_name===r);if(o)return o}}async function Iw(e,t,s,r,i,o,n,a){let A=e,c=t.input_name||e,u=np(t),l=t.input_prerelease,p=t.input_target_commitish,g=t.input_make_latest,h="";p&&(h=` using commit "${p}"`),console.log(`\u{1F469}\u200D\u{1F3ED} Creating new GitHub release for tag ${A}${h}...`);try{return(await s.createRelease({owner:r,repo:i,tag_name:A,name:c,body:u,draft:!0,prerelease:l,target_commitish:p,discussion_category_name:o,generate_release_notes:n,make_latest:g})).data}catch(d){switch(console.log(`\u26A0\uFE0F GitHub release failed with status: ${d.status}`),console.log(`${JSON.stringify(d.response.data)}`),d.status){case 403:throw console.log("Skip retry \u2014 your GitHub token/PAT does not have the required permission to create a release"),d;case 404:throw console.log("Skip retry - discussion category mismatch"),d;case 422:if(d.response?.data?.errors?.[0]?.code==="already_exists")console.log("\u26A0\uFE0F Release already exists (race condition detected), retrying to find and update existing release...");else throw console.log("Skip retry - validation failed"),d;break}return console.log(`retrying... (${a-1} retries remaining)`),ap(t,s,a-1)}}var kw=require("process");async function R_(){try{let e=fw(kw.env);if(!e.input_tag_name&&!ja(e.github_ref)&&!e.input_draft)throw new Error("\u26A0\uFE0F GitHub Releases requires a tag");if(e.input_files){let o=Bw(e.input_files,e.input_working_directory);if(o.forEach(n=>{if(e.input_fail_on_unmatched_files)throw new Error(`\u26A0\uFE0F Pattern '${n}' does not match any files.`);console.warn(`\u{1F914} Pattern '${n}' does not match any files.`)}),o.length>0&&e.input_fail_on_unmatched_files)throw new Error("\u26A0\uFE0F There were unmatched files")}let t=WC(e.github_token,{throttle:{onRateLimit:(o,n)=>{if(console.warn(`Request quota exhausted for request ${n.method} ${n.url}`),n.request.retryCount===0)return console.log(`Retrying after ${o} seconds!`),!0},onAbuseLimit:(o,n)=>{console.warn(`Abuse detected for request ${n.method} ${n.url}`)}}}),s=new za(t),r=await ap(e,s),i=new Set;if(e.input_files&&e.input_files.length>0){let o=Qw(e.input_files,e.input_working_directory);if(o.length==0){if(e.input_fail_on_unmatched_files)throw new Error(`\u26A0\uFE0F ${e.input_files} does not include a valid file.`);console.warn(`\u{1F914} ${e.input_files} does not include a valid file.`)}let n=r.assets,a=async c=>{let u=await vw(e,s,mw(r.upload_url),c,n);return u?u.id:void 0},A;if(!e.input_preserve_order)A=await Promise.all(o.map(a));else{A=[];for(let c of o)A.push(await a(c))}i=new Set(A.filter(c=>c!==void 0))}console.log("Finalizing release..."),r=await Ap(e,s,r),console.log("Getting assets list...");{let o=[];i.size>0&&(o=(await cp(e,s,r)).filter(a=>i.has(a.id)).map(a=>{let{uploader:A,...c}=a;return c})),$i("assets",o)}console.log(`\u{1F389} Release ready at ${r.html_url}`),$i("url",r.html_url),$i("id",r.id.toString()),$i("upload_url",r.upload_url)}catch(e){KB(e.message)}}R_(); +`+h:m=h||d;let E=e.input_prerelease!==void 0?e.input_prerelease:c.prerelease,f=e.input_make_latest;return(await t.updateRelease({owner:r,repo:i,release_id:u,tag_name:p,target_commitish:l,name:g,body:m,draft:c.draft,prerelease:E,discussion_category_name:n,generate_release_notes:a,make_latest:f})).data}catch(A){if(A.status!==404)throw console.log(`\u26A0\uFE0F Unexpected error fetching GitHub release for tag ${e.github_ref}: ${A}`),A;return await Iw(o,e,t,r,i,n,a,s)}},Ap=async(e,t,s,r=3)=>{if(e.input_draft===!0)return s;if(r<=0)throw console.log("\u274C Too many retries. Aborting..."),new Error("Too many retries.");let[i,o]=e.github_repository.split("/");try{let{data:n}=await t.finalizeRelease({owner:i,repo:o,release_id:s.id,make_latest:e.input_make_latest});return n}catch(n){return console.warn(`error finalizing release: ${n}`),console.log(`retrying... (${r-1} retries remaining)`),Ap(e,t,s,r-1)}},cp=async(e,t,s,r=3)=>{if(r<=0)throw console.log("\u274C Too many retries. Aborting..."),new Error("Too many retries.");let[i,o]=e.github_repository.split("/");try{return await t.listReleaseAssets({owner:i,repo:o,release_id:s.id})}catch(n){return console.warn(`error listing assets of release: ${n}`),console.log(`retrying... (${r-1} retries remaining)`),cp(e,t,s,r-1)}};async function D_(e,t,s,r){try{let{data:i}=await e.getReleaseByTag({owner:t,repo:s,tag:r});return i}catch(i){if(i.status===404)return;throw i}}async function Iw(e,t,s,r,i,o,n,a){let A=e,c=t.input_name||e,u=np(t),l=t.input_prerelease,p=t.input_target_commitish,g=t.input_make_latest,h="";p&&(h=` using commit "${p}"`),console.log(`\u{1F469}\u200D\u{1F3ED} Creating new GitHub release for tag ${A}${h}...`);try{return(await s.createRelease({owner:r,repo:i,tag_name:A,name:c,body:u,draft:!0,prerelease:l,target_commitish:p,discussion_category_name:o,generate_release_notes:n,make_latest:g})).data}catch(d){switch(console.log(`\u26A0\uFE0F GitHub release failed with status: ${d.status}`),console.log(`${JSON.stringify(d.response.data)}`),d.status){case 403:throw console.log("Skip retry \u2014 your GitHub token/PAT does not have the required permission to create a release"),d;case 404:throw console.log("Skip retry - discussion category mismatch"),d;case 422:if(d.response?.data?.errors?.[0]?.code==="already_exists")console.log("\u26A0\uFE0F Release already exists (race condition detected), retrying to find and update existing release...");else throw console.log("Skip retry - validation failed"),d;break}return console.log(`retrying... (${a-1} retries remaining)`),ap(t,s,a-1)}}var kw=require("process");async function R_(){try{let e=fw(kw.env);if(!e.input_tag_name&&!ja(e.github_ref)&&!e.input_draft)throw new Error("\u26A0\uFE0F GitHub Releases requires a tag");if(e.input_files){let o=Bw(e.input_files,e.input_working_directory);if(o.forEach(n=>{if(e.input_fail_on_unmatched_files)throw new Error(`\u26A0\uFE0F Pattern '${n}' does not match any files.`);console.warn(`\u{1F914} Pattern '${n}' does not match any files.`)}),o.length>0&&e.input_fail_on_unmatched_files)throw new Error("\u26A0\uFE0F There were unmatched files")}let t=WC(e.github_token,{throttle:{onRateLimit:(o,n)=>{if(console.warn(`Request quota exhausted for request ${n.method} ${n.url}`),n.request.retryCount===0)return console.log(`Retrying after ${o} seconds!`),!0},onAbuseLimit:(o,n)=>{console.warn(`Abuse detected for request ${n.method} ${n.url}`)}}}),s=new za(t),r=await ap(e,s),i=new Set;if(e.input_files&&e.input_files.length>0){let o=Qw(e.input_files,e.input_working_directory);if(o.length==0){if(e.input_fail_on_unmatched_files)throw new Error(`\u26A0\uFE0F ${e.input_files} does not include a valid file.`);console.warn(`\u{1F914} ${e.input_files} does not include a valid file.`)}let n=r.assets,a=async c=>{let u=await vw(e,s,mw(r.upload_url),c,n);return u?u.id:void 0},A;if(!e.input_preserve_order)A=await Promise.all(o.map(a));else{A=[];for(let c of o)A.push(await a(c))}i=new Set(A.filter(c=>c!==void 0))}console.log("Finalizing release..."),r=await Ap(e,s,r),console.log("Getting assets list...");{let o=[];i.size>0&&(o=(await cp(e,s,r)).filter(a=>i.has(a.id)).map(a=>{let{uploader:A,...c}=a;return c})),$i("assets",o)}console.log(`\u{1F389} Release ready at ${r.html_url}`),$i("url",r.html_url),$i("id",r.id.toString()),$i("upload_url",r.upload_url)}catch(e){KB(e.message)}}R_(); /*! Bundled license information: undici/lib/web/fetch/body.js: diff --git a/src/github.ts b/src/github.ts index 9d73185..98d82af 100644 --- a/src/github.ts +++ b/src/github.ts @@ -494,7 +494,11 @@ export const listReleaseAssets = async ( }; /** - * Finds a release by tag name from all a repository's releases. + * Finds a release by tag name. + * + * Uses the direct getReleaseByTag API for O(1) lookup instead of iterating + * through all releases. This also avoids GitHub's API pagination limit of + * 10000 results which would cause failures for repositories with many releases. * * @param releaser - The GitHub API wrapper for release operations * @param owner - The owner of the repository @@ -508,16 +512,17 @@ export async function findTagFromReleases( repo: string, tag: string, ): Promise { - for await (const { data: releases } of releaser.allReleases({ - owner, - repo, - })) { - const release = releases.find((release) => release.tag_name === tag); - if (release) { - return release; + try { + const { data: release } = await releaser.getReleaseByTag({ owner, repo, tag }); + return release; + } catch (error) { + // Release not found (404) or other error - return undefined to allow creation + if (error.status === 404) { + return undefined; } + // Re-throw unexpected errors + throw error; } - return undefined; } async function createRelease(