diff --git a/README.md b/README.md index 64339a5..3b5aac5 100644 --- a/README.md +++ b/README.md @@ -240,6 +240,11 @@ will retain its original info. existing draft release, set `draft: true` to keep it draft; if `draft` is omitted, the action will publish that draft after uploading assets. +💡 When the action creates a new release that uploads assets, it stages the release +as a draft first, uploads the assets, and then publishes it unless `draft: true` +keeps it as a draft. This keeps new prereleases compatible with GitHub immutable +releases. + 💡 `files` is glob-based, so literal filenames that contain glob metacharacters such as `[` or `]` must be escaped in the pattern. diff --git a/__tests__/github.test.ts b/__tests__/github.test.ts index fadb51d..ba27b85 100644 --- a/__tests__/github.test.ts +++ b/__tests__/github.test.ts @@ -517,7 +517,7 @@ describe('github', () => { ); }); - it('creates published prereleases without the forced draft-first path', async () => { + it('creates published prereleases without the forced draft-first path when no assets are configured', async () => { const prereleaseConfig = { ...config, input_prerelease: true, @@ -564,6 +564,54 @@ describe('github', () => { ); }); + it('creates draft prereleases when assets are configured so uploads can finish before publish', async () => { + const prereleaseConfig = { + ...config, + input_prerelease: true, + input_draft: false, + input_files: ['draft-false.txt'], + }; + const createdRelease: Release = { + id: 1, + upload_url: 'test', + html_url: 'test', + tag_name: 'v1.0.0', + name: 'test', + body: 'test', + target_commitish: 'main', + draft: true, + prerelease: true, + assets: [], + }; + + const createReleaseSpy = vi.fn(async () => ({ data: createdRelease })); + const mockReleaser: Releaser = { + getReleaseByTag: () => Promise.reject({ status: 404 }), + createRelease: createReleaseSpy, + updateRelease: () => Promise.reject('Not implemented'), + finalizeRelease: () => Promise.reject('Not implemented'), + allReleases: async function* () { + yield { data: [createdRelease] }; + }, + listReleaseAssets: () => Promise.reject('Not implemented'), + deleteReleaseAsset: () => Promise.reject('Not implemented'), + deleteRelease: () => Promise.reject('Not implemented'), + updateReleaseAsset: () => Promise.reject('Not implemented'), + uploadReleaseAsset: () => Promise.reject('Not implemented'), + } as const; + + const result = await release(prereleaseConfig, mockReleaser, 1); + + assert.equal(result.release.id, createdRelease.id); + assert.equal(result.created, true); + expect(createReleaseSpy).toHaveBeenCalledWith( + expect.objectContaining({ + draft: true, + prerelease: true, + }), + ); + }); + it('retries upload after deleting conflicting asset on 422 already_exists race', async () => { const uploadReleaseAsset = vi .fn() diff --git a/action.yml b/action.yml index 74f6dcd..32b7bfd 100644 --- a/action.yml +++ b/action.yml @@ -16,7 +16,7 @@ inputs: description: "Gives a tag name. Defaults to github.ref_name. refs/tags/ values are normalized to ." required: false draft: - description: "Keeps the release as a draft. Defaults to false. When reusing an existing draft release, set this to true to keep it draft; omit it to publish after upload." + description: "Keeps the release as a draft. Defaults to false. New releases that upload assets are staged as drafts first; set this to true to keep the release draft instead of publishing it after upload." required: false prerelease: description: "Identify the release as a prerelease. Defaults to false" diff --git a/dist/index.js b/dist/index.js index 5b67399..d4b690c 100644 --- a/dist/index.js +++ b/dist/index.js @@ -52,7 +52,7 @@ globstar while`,e,l,t,p,g),this.matchOne(e.slice(l),t.slice(p),s))return this.de ${i.data.body}`:r.body=i.data.body}return r.body=r.body?this.truncateReleaseNotes(r.body):void 0,r}truncateReleaseNotes(t){return t.substring(0,124999)}async createRelease(t){return this.github.rest.repos.createRelease(await this.prepareReleaseMutation(t))}async updateRelease(t){return this.github.rest.repos.updateRelease(await this.prepareReleaseMutation(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 deleteRelease(t){await this.github.rest.repos.deleteRelease(t)}async updateReleaseAsset(t){return await this.github.rest.repos.updateReleaseAsset(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})}},N_=e=>({name:(0,Rw.basename)(e),mime:G_(e),size:(0,vw.statSync)(e).size}),G_=e=>(0,Dw.lookup)(e)||"application/octet-stream",cp=(e,t)=>t.name===e||t.name===yw(e)||t.label===e,M_=e=>{let t=e?.status??e?.response?.status,s=e?.request?.url,r=e?.message,i=typeof s=="string"&&(/\/releases\/assets\//.test(s)||/\/releases\/\d+\/assets(?:\?|$)/.test(s));return t===404&&(i||typeof r=="string"&&r.includes("update-a-release-asset"))},Tw=async(e,t,s,r,i)=>{let[o,n]=e.github_repository.split("/"),{name:a,mime:A,size:c}=N_(r),u=s.match(/\/releases\/(\d+)\/assets/),l=u?Number(u[1]):void 0,p=i.find(m=>cp(a,m));if(p){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:p.id||1,owner:o,repo:n})}console.log(`\u2B06\uFE0F Uploading ${a}...`);let g=new URL(s);g.searchParams.append("name",a);let h=async(m,C=3)=>{if(l!==void 0)for(let B=1;B<=C;B++){let S=(await t.listReleaseAssets({owner:o,repo:n,release_id:l})).find(m);if(S)return S;BsetTimeout(G,1e3))}},E=async()=>{let m=await(0,kw.open)(r);try{return await t.uploadReleaseAsset({url:g.toString(),size:c,mime:A,token:e.github_token,data:m.readableWebStream({type:"bytes"})})}finally{await m.close()}},f=async m=>{if(!m.name||m.name===a||!m.id)return m;console.log(`\u270F\uFE0F Restoring asset label to ${a}...`);let C=async B=>{let{data:b}=await t.updateReleaseAsset({owner:o,repo:n,asset_id:B,name:m.name,label:a});return b};try{return await C(m.id)}catch(B){if((B?.status??B?.response?.status)===404&&l!==void 0)try{let S=await h(G=>G.id===m.id||G.name===m.name);if(S)return await C(S.id)}catch(S){console.warn(`error refreshing release assets for ${a}: ${S}`)}return console.warn(`error updating release asset label for ${a}: ${B}`),m}},d=async m=>{let C=m.data;if(m.status!==201)throw new Error(`Failed to upload release asset ${a}. received status code ${m.status} ${C.message} ${JSON.stringify(C.errors)}`);let B=await f(C);return console.log(`\u2705 Uploaded ${a}`),B};try{return await d(await E())}catch(m){let C=m?.status??m?.response?.status,B=m?.response?.data;if(l!==void 0&&M_(m))try{let b=await h(S=>cp(a,S));if(b)return console.warn(`error updating release asset metadata for ${a}: ${m}. Matching asset is present after refresh; continuing...`),b}catch(b){console.warn(`error refreshing release assets after metadata update failure: ${b}`)}if(e.input_overwrite_files!==!1&&C===422&&B?.errors?.[0]?.code==="already_exists"&&l!==void 0){console.log(`\u26A0\uFE0F Asset ${a} already exists (race condition), refreshing assets and retrying once...`);let S=(await t.listReleaseAssets({owner:o,repo:n,release_id:l})).find(G=>cp(a,G));if(S)return await t.deleteReleaseAsset({owner:o,repo:n,asset_id:S.id}),await d(await E())}throw m}},lp=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=Ap(e.input_tag_name)||(fo(e.github_ref)?e.github_ref.replace("refs/tags/",""):""),n=e.input_discussion_category_name,a=e.input_generate_release_notes,A=e.input_previous_tag;a&&A&&console.log(`\u{1F4DD} Generating release notes using previous tag ${A}`);try{let c=await Fw(t,r,i,o);if(c===void 0)return await xw(o,e,t,r,i,n,a,s,A);let u=c;console.log(`Found release ${u.name} (with id=${u.id})`);let l=u.id,p;e.input_target_commitish&&e.input_target_commitish!==u.target_commitish?(console.log(`Updating commit from "${u.target_commitish}" to "${e.input_target_commitish}"`),p=e.input_target_commitish):p=u.target_commitish;let g=o,h=e.input_name||u.name||o,E=ap(e)||"",f=u.body||"",d;e.input_append_body&&E&&f?d=f+` -`+E:d=E||f;let m=e.input_prerelease!==void 0?e.input_prerelease:u.prerelease,C=e.input_make_latest;return{release:(await t.updateRelease({owner:r,repo:i,release_id:l,tag_name:g,target_commitish:p,name:h,body:d,draft:u.draft,prerelease:m,discussion_category_name:n,generate_release_notes:a,make_latest:C,previous_tag_name:A})).data,created:!1}}catch(c){if(c.status!==404)throw console.log(`\u26A0\uFE0F Unexpected error fetching GitHub release for tag ${e.github_ref}: ${c}`),c;return await xw(o,e,t,r,i,n,a,s,A)}},up=async(e,t,s,r=!1,i=3)=>{if(e.input_draft===!0||s.draft===!1)return s;if(i<=0)throw console.log("\u274C Too many retries. Aborting..."),new Error("Too many retries.");let[o,n]=e.github_repository.split("/");try{let{data:a}=await t.finalizeRelease({owner:o,repo:n,release_id:s.id,make_latest:e.input_make_latest});return a}catch(a){if(console.warn(`error finalizing release: ${a}`),r&&s.draft&&V_(a)){let A=!1;try{console.log(`\u{1F9F9} Deleting draft release ${s.id} for tag ${s.tag_name} because tag creation is blocked by repository rules...`),await t.deleteRelease({owner:o,repo:n,release_id:s.id}),A=!0}catch(u){console.warn(`error deleting orphan draft release ${s.id}: ${u}`)}let c=A?`Deleted draft release ${s.id} to avoid leaving an orphaned draft release.`:`Failed to delete draft release ${s.id}; manual cleanup may still be required.`;throw new Error(`Tag creation for ${s.tag_name} is blocked by repository rules. ${c}`)}return console.log(`retrying... (${i-1} retries remaining)`),up(e,t,s,r,i-1)}},pp=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)`),pp(e,t,s,r-1)}};async function Fw(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}}var L_=1e3,__=2;async function Y_(e){await new Promise(t=>setTimeout(t,e))}async function O_(e,t,s,r){let i=[],o=0;for await(let n of e.allReleases({owner:t,repo:s}))if(i.push(...n.data.filter(a=>a.tag_name===r)),o+=1,o>=__)break;return i}function J_(e,t){return t&&e.some(s=>s.id===t.id)||e.length===0?t:[...e].sort((s,r)=>s.draft!==r.draft?Number(s.draft)-Number(r.draft):s.id-r.id)[0]}async function P_(e,t,s,r,i,o){let n=Array.from(new Map(o.map(a=>[a.id,a])).values());for(let a of n)if(!(a.id===i||!a.draft||a.assets.length>0))try{console.log(`\u{1F9F9} Removing duplicate draft release ${a.id} for tag ${r}...`),await e.deleteRelease({owner:t,repo:s,release_id:a.id})}catch(A){console.warn(`error deleting duplicate release ${a.id}: ${A}`)}}async function H_(e,t,s,r,i,o){let n=Math.max(o,1);for(let a=1;a<=n;a+=1){let A;try{A=await Fw(e,t,s,r)}catch(l){console.warn(`error reloading release for tag ${r}: ${l}`)}let c=[];try{c=await O_(e,t,s,r)}catch(l){console.warn(`error listing recent releases for tag ${r}: ${l}`)}let u=J_(c,A);if(u)return u.id!==i.id&&console.log(`\u21AA\uFE0F Using release ${u.id} for tag ${r} instead of duplicate draft ${i.id}`),await P_(e,t,s,r,u.id,[i,...c]),u;as==="pre_receive"&&typeof r=="string"&&r.includes("creations being restricted"))}var Sw=require("process");async function q_(){try{let e=Cw(Sw.env);if(!e.input_tag_name&&!fo(e.github_ref)&&!e.input_draft)throw new Error("\u26A0\uFE0F GitHub Releases requires a tag");if(e.input_files){let a=bw(e.input_files,e.input_working_directory);if(a.forEach(A=>{if(e.input_fail_on_unmatched_files)throw new Error(`\u26A0\uFE0F Pattern '${A}' does not match any files.`);console.warn(`\u{1F914} Pattern '${A}' does not match any files.`)}),a.length>0&&e.input_fail_on_unmatched_files)throw new Error("\u26A0\uFE0F There were unmatched files")}let t=ZC(e.github_token,{throttle:{onRateLimit:(a,A)=>{if(console.warn(`Request quota exhausted for request ${A.method} ${A.url}`),A.request.retryCount===0)return console.log(`Retrying after ${a} seconds!`),!0},onAbuseLimit:(a,A)=>{console.warn(`Abuse detected for request ${A.method} ${A.url}`)}}}),s=new Za(t),r=await lp(e,s),i=r.release,o=r.created,n=new Set;if(e.input_files&&e.input_files.length>0){let a=ww(e.input_files,e.input_working_directory);if(a.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 A=i.assets,c=async l=>{let p=await Tw(e,s,Bw(i.upload_url),l,A);return p?p.id:void 0},u;if(!e.input_preserve_order)u=await Promise.all(a.map(c));else{u=[];for(let l of a)u.push(await c(l))}n=new Set(u.filter(l=>l!==void 0))}console.log("Finalizing release..."),i=await up(e,s,i,o),console.log("Getting assets list...");{let a=[];n.size>0&&(a=(await pp(e,s,i)).filter(c=>n.has(c.id)).map(c=>{let{uploader:u,...l}=c;return l})),eo("assets",a)}console.log(`\u{1F389} Release ready at ${i.html_url}`),eo("url",i.html_url),eo("id",i.id.toString()),eo("upload_url",i.upload_url)}catch(e){eC(e.message)}}q_(); +`+E:d=E||f;let m=e.input_prerelease!==void 0?e.input_prerelease:u.prerelease,C=e.input_make_latest;return{release:(await t.updateRelease({owner:r,repo:i,release_id:l,tag_name:g,target_commitish:p,name:h,body:d,draft:u.draft,prerelease:m,discussion_category_name:n,generate_release_notes:a,make_latest:C,previous_tag_name:A})).data,created:!1}}catch(c){if(c.status!==404)throw console.log(`\u26A0\uFE0F Unexpected error fetching GitHub release for tag ${e.github_ref}: ${c}`),c;return await xw(o,e,t,r,i,n,a,s,A)}},up=async(e,t,s,r=!1,i=3)=>{if(e.input_draft===!0||s.draft===!1)return s;if(i<=0)throw console.log("\u274C Too many retries. Aborting..."),new Error("Too many retries.");let[o,n]=e.github_repository.split("/");try{let{data:a}=await t.finalizeRelease({owner:o,repo:n,release_id:s.id,make_latest:e.input_make_latest});return a}catch(a){if(console.warn(`error finalizing release: ${a}`),r&&s.draft&&V_(a)){let A=!1;try{console.log(`\u{1F9F9} Deleting draft release ${s.id} for tag ${s.tag_name} because tag creation is blocked by repository rules...`),await t.deleteRelease({owner:o,repo:n,release_id:s.id}),A=!0}catch(u){console.warn(`error deleting orphan draft release ${s.id}: ${u}`)}let c=A?`Deleted draft release ${s.id} to avoid leaving an orphaned draft release.`:`Failed to delete draft release ${s.id}; manual cleanup may still be required.`;throw new Error(`Tag creation for ${s.tag_name} is blocked by repository rules. ${c}`)}return console.log(`retrying... (${i-1} retries remaining)`),up(e,t,s,r,i-1)}},pp=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)`),pp(e,t,s,r-1)}};async function Fw(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}}var L_=1e3,__=2;async function Y_(e){await new Promise(t=>setTimeout(t,e))}async function O_(e,t,s,r){let i=[],o=0;for await(let n of e.allReleases({owner:t,repo:s}))if(i.push(...n.data.filter(a=>a.tag_name===r)),o+=1,o>=__)break;return i}function J_(e,t){return t&&e.some(s=>s.id===t.id)||e.length===0?t:[...e].sort((s,r)=>s.draft!==r.draft?Number(s.draft)-Number(r.draft):s.id-r.id)[0]}async function P_(e,t,s,r,i,o){let n=Array.from(new Map(o.map(a=>[a.id,a])).values());for(let a of n)if(!(a.id===i||!a.draft||a.assets.length>0))try{console.log(`\u{1F9F9} Removing duplicate draft release ${a.id} for tag ${r}...`),await e.deleteRelease({owner:t,repo:s,release_id:a.id})}catch(A){console.warn(`error deleting duplicate release ${a.id}: ${A}`)}}async function H_(e,t,s,r,i,o){let n=Math.max(o,1);for(let a=1;a<=n;a+=1){let A;try{A=await Fw(e,t,s,r)}catch(l){console.warn(`error reloading release for tag ${r}: ${l}`)}let c=[];try{c=await O_(e,t,s,r)}catch(l){console.warn(`error listing recent releases for tag ${r}: ${l}`)}let u=J_(c,A);if(u)return u.id!==i.id&&console.log(`\u21AA\uFE0F Using release ${u.id} for tag ${r} instead of duplicate draft ${i.id}`),await P_(e,t,s,r,u.id,[i,...c]),u;a0,h=t.input_target_commitish,E=t.input_make_latest,f="";h&&(f=` using commit "${h}"`),console.log(`\u{1F469}\u200D\u{1F3ED} Creating new GitHub release for tag ${c}${f}...`);try{let d=await s.createRelease({owner:r,repo:i,tag_name:c,name:u,body:l,draft:g,prerelease:p,target_commitish:h,discussion_category_name:o,generate_release_notes:n,make_latest:E,previous_tag_name:A}),m=await H_(s,r,i,c,d.data,a);return{release:m,created:m.id===d.data.id}}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)`),lp(t,s,a-1)}}function V_(e){let t=e?.response?.data?.errors;return!Array.isArray(t)||e?.status!==422?!1:t.some(({field:s,message:r})=>s==="pre_receive"&&typeof r=="string"&&r.includes("creations being restricted"))}var Sw=require("process");async function q_(){try{let e=Cw(Sw.env);if(!e.input_tag_name&&!fo(e.github_ref)&&!e.input_draft)throw new Error("\u26A0\uFE0F GitHub Releases requires a tag");if(e.input_files){let a=bw(e.input_files,e.input_working_directory);if(a.forEach(A=>{if(e.input_fail_on_unmatched_files)throw new Error(`\u26A0\uFE0F Pattern '${A}' does not match any files.`);console.warn(`\u{1F914} Pattern '${A}' does not match any files.`)}),a.length>0&&e.input_fail_on_unmatched_files)throw new Error("\u26A0\uFE0F There were unmatched files")}let t=ZC(e.github_token,{throttle:{onRateLimit:(a,A)=>{if(console.warn(`Request quota exhausted for request ${A.method} ${A.url}`),A.request.retryCount===0)return console.log(`Retrying after ${a} seconds!`),!0},onAbuseLimit:(a,A)=>{console.warn(`Abuse detected for request ${A.method} ${A.url}`)}}}),s=new Za(t),r=await lp(e,s),i=r.release,o=r.created,n=new Set;if(e.input_files&&e.input_files.length>0){let a=ww(e.input_files,e.input_working_directory);if(a.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 A=i.assets,c=async l=>{let p=await Tw(e,s,Bw(i.upload_url),l,A);return p?p.id:void 0},u;if(!e.input_preserve_order)u=await Promise.all(a.map(c));else{u=[];for(let l of a)u.push(await c(l))}n=new Set(u.filter(l=>l!==void 0))}console.log("Finalizing release..."),i=await up(e,s,i,o),console.log("Getting assets list...");{let a=[];n.size>0&&(a=(await pp(e,s,i)).filter(c=>n.has(c.id)).map(c=>{let{uploader:u,...l}=c;return l})),eo("assets",a)}console.log(`\u{1F389} Release ready at ${i.html_url}`),eo("url",i.html_url),eo("id",i.id.toString()),eo("upload_url",i.upload_url)}catch(e){eC(e.message)}}q_(); /*! Bundled license information: undici/lib/web/fetch/body.js: diff --git a/src/github.ts b/src/github.ts index 0a291d1..a9162c1 100644 --- a/src/github.ts +++ b/src/github.ts @@ -875,7 +875,8 @@ async function createRelease( const name = config.input_name || tag; const body = releaseBody(config); const prerelease = config.input_prerelease; - const draft = prerelease === true ? config.input_draft === true : true; + const draft = + config.input_draft === true || prerelease !== true || (config.input_files?.length ?? 0) > 0; const target_commitish = config.input_target_commitish; const make_latest = config.input_make_latest; let commitMessage: string = '';