From 4aadb0df8b21e66c030caaebf71981d110a99d09 Mon Sep 17 00:00:00 2001 From: Rui Chen Date: Sat, 14 Mar 2026 21:14:27 -0400 Subject: [PATCH] fix: restore dotfile asset labels (#749) Signed-off-by: Rui Chen --- __tests__/github.test.ts | 127 +++++++++++++++++++++++++++++++++++++++ dist/index.js | 4 +- src/github.ts | 45 ++++++++++++-- 3 files changed, 169 insertions(+), 7 deletions(-) diff --git a/__tests__/github.test.ts b/__tests__/github.test.ts index 8fe3b2d..1959f0f 100644 --- a/__tests__/github.test.ts +++ b/__tests__/github.test.ts @@ -6,8 +6,12 @@ import { release, Release, Releaser, + upload, } from '../src/github'; +import { mkdtempSync, rmSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; import { assert, describe, expect, it, vi } from 'vitest'; describe('github', () => { @@ -78,6 +82,7 @@ describe('github', () => { 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; @@ -201,6 +206,7 @@ describe('github', () => { 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'), }; @@ -258,6 +264,7 @@ describe('github', () => { 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; @@ -377,6 +384,7 @@ describe('github', () => { listReleaseAssets: () => Promise.reject('Not implemented'), deleteReleaseAsset: () => Promise.reject('Not implemented'), deleteRelease: deleteReleaseSpy, + updateReleaseAsset: () => Promise.reject('Not implemented'), uploadReleaseAsset: () => Promise.reject('Not implemented'), }; @@ -428,6 +436,7 @@ describe('github', () => { listReleaseAssets: () => Promise.reject('Not implemented'), deleteReleaseAsset: () => Promise.reject('Not implemented'), deleteRelease: deleteReleaseSpy, + updateReleaseAsset: () => Promise.reject('Not implemented'), uploadReleaseAsset: () => Promise.reject('Not implemented'), }; @@ -441,4 +450,122 @@ describe('github', () => { }); }); }); + + describe('upload', () => { + it('restores a dotfile label when GitHub normalizes the uploaded asset name', async () => { + const tempDir = mkdtempSync(join(tmpdir(), 'gh-release-dotfile-')); + const dotfilePath = join(tempDir, '.config'); + writeFileSync(dotfilePath, 'config'); + + const updateReleaseAssetSpy = vi.fn(async () => ({ + data: { + id: 1, + name: 'default.config', + label: '.config', + }, + })); + const releaser: Releaser = { + getReleaseByTag: () => Promise.reject('Not implemented'), + createRelease: () => Promise.reject('Not implemented'), + updateRelease: () => Promise.reject('Not implemented'), + finalizeRelease: () => Promise.reject('Not implemented'), + allReleases: async function* () { + throw new Error('Not implemented'); + }, + listReleaseAssets: () => Promise.reject('Not implemented'), + deleteReleaseAsset: () => Promise.reject('Not implemented'), + deleteRelease: () => Promise.reject('Not implemented'), + updateReleaseAsset: updateReleaseAssetSpy, + uploadReleaseAsset: () => + Promise.resolve({ + status: 201, + data: { + id: 1, + name: 'default.config', + label: '', + }, + }), + }; + + try { + const result = await upload( + config, + releaser, + 'https://uploads.example.test/assets', + dotfilePath, + [], + ); + + expect(updateReleaseAssetSpy).toHaveBeenCalledWith({ + owner: 'owner', + repo: 'repo', + asset_id: 1, + name: 'default.config', + label: '.config', + }); + expect(result).toEqual({ + id: 1, + name: 'default.config', + label: '.config', + }); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it('matches an existing asset by label when overwriting a dotfile', async () => { + const tempDir = mkdtempSync(join(tmpdir(), 'gh-release-dotfile-')); + const dotfilePath = join(tempDir, '.config'); + writeFileSync(dotfilePath, 'config'); + + const deleteReleaseAssetSpy = vi.fn(async () => undefined); + const releaser: Releaser = { + getReleaseByTag: () => Promise.reject('Not implemented'), + createRelease: () => Promise.reject('Not implemented'), + updateRelease: () => Promise.reject('Not implemented'), + finalizeRelease: () => Promise.reject('Not implemented'), + allReleases: async function* () { + throw new Error('Not implemented'); + }, + listReleaseAssets: () => Promise.reject('Not implemented'), + deleteReleaseAsset: deleteReleaseAssetSpy, + deleteRelease: () => Promise.reject('Not implemented'), + updateReleaseAsset: () => + Promise.resolve({ + data: { + id: 2, + name: 'default.config', + label: '.config', + }, + }), + uploadReleaseAsset: () => + Promise.resolve({ + status: 201, + data: { + id: 2, + name: 'default.config', + label: '', + }, + }), + }; + + try { + await upload(config, releaser, 'https://uploads.example.test/assets', dotfilePath, [ + { + id: 1, + name: 'default.config', + label: '.config', + }, + ]); + + expect(deleteReleaseAssetSpy).toHaveBeenCalledWith({ + asset_id: 1, + owner: 'owner', + repo: 'repo', + }); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + }); }); diff --git a/dist/index.js b/dist/index.js index c1f0fea..e7de74a 100644 --- a/dist/index.js +++ b/dist/index.js @@ -51,9 +51,9 @@ globstar while`,e,l,t,p,g),this.matchOne(e.slice(l),t.slice(p),s))return this.de ${s.data.body}`:t.body=s.data.body}return t.body=t.body?this.truncateReleaseNotes(t.body):void 0,this.github.rest.repos.createRelease(t)}async updateRelease(t){if(typeof t.make_latest=="string"&&!["true","false","legacy"].includes(t.make_latest)&&(t.make_latest=void 0),t.generate_release_notes){let s=await this.getReleaseNotes(t);t.generate_release_notes=!1,t.body?t.body=`${t.body} -${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 deleteRelease(t){await this.github.rest.repos.deleteRelease(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})}},k_=e=>({name:(0,xw.basename)(e),mime:D_(e),size:(0,ww.statSync)(e).size}),D_=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}=k_(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} +${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 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})}},k_=e=>({name:(0,xw.basename)(e),mime:D_(e),size:(0,ww.statSync)(e).size}),D_=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}=k_(r),u=i.find(({name:g,label:h})=>g===a||g===Cw(a)||h===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 kw(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)||"",E=c.body||"",m;e.input_append_body&&h&&E?m=E+` +${JSON.stringify(h.errors)}`);if(h.name&&h.name!==a&&h.id){console.log(`\u270F\uFE0F Restoring asset label to ${a}...`);try{let{data:E}=await t.updateReleaseAsset({owner:o,repo:n,asset_id:h.id,name:h.name,label:a});return console.log(`\u2705 Uploaded ${a}`),E}catch(E){console.warn(`error updating release asset label for ${a}: ${E}`)}}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 kw(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)||"",E=c.body||"",m;e.input_append_body&&h&&E?m=E+` `+h:m=h||E;let d=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:d,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||s.draft===!1)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 kw(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 R_=1e3,T_=2;async function F_(e){await new Promise(t=>setTimeout(t,e))}async function S_(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>=T_)break;return i}function U_(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 N_(e,t,s,r,i,o){for(let n of o)if(!(n.id===i||!n.draft||n.assets.length>0))try{console.log(`\u{1F9F9} Removing duplicate draft release ${n.id} for tag ${r}...`),await e.deleteRelease({owner:t,repo:s,release_id:n.id})}catch(a){console.warn(`error deleting duplicate release ${n.id}: ${a}`)}}async function G_(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 kw(e,t,s,r)}catch(l){console.warn(`error reloading release for tag ${r}: ${l}`)}let c=[];try{c=await S_(e,t,s,r)}catch(l){console.warn(`error listing recent releases for tag ${r}: ${l}`)}let u=U_(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 N_(e,t,s,r,u.id,c),u;a{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)}}M_(); /*! Bundled license information: diff --git a/src/github.ts b/src/github.ts index 7d4efdb..38425ef 100644 --- a/src/github.ts +++ b/src/github.ts @@ -23,7 +23,7 @@ export interface Release { target_commitish: string; draft: boolean; prerelease: boolean; - assets: Array<{ id: number; name: string }>; + assets: Array<{ id: number; name: string; label?: string | null }>; } export interface Releaser { @@ -71,12 +71,20 @@ export interface Releaser { owner: string; repo: string; release_id: number; - }): Promise>; + }): Promise>; deleteReleaseAsset(params: { owner: string; repo: string; asset_id: number }): Promise; deleteRelease(params: { owner: string; repo: string; release_id: number }): Promise; + updateReleaseAsset(params: { + owner: string; + repo: string; + asset_id: number; + name: string; + label: string; + }): Promise<{ data: any }>; + uploadReleaseAsset(params: { url: string; size: number; @@ -211,7 +219,7 @@ export class GitHubReleaser implements Releaser { owner: string; repo: string; release_id: number; - }): Promise> { + }): Promise> { return this.github.paginate(this.github.rest.repos.listReleaseAssets, { ...params, per_page: 100, @@ -230,6 +238,16 @@ export class GitHubReleaser implements Releaser { await this.github.rest.repos.deleteRelease(params); } + async updateReleaseAsset(params: { + owner: string; + repo: string; + asset_id: number; + name: string; + label: string; + }): Promise<{ data: any }> { + return await this.github.rest.repos.updateReleaseAsset(params); + } + async uploadReleaseAsset(params: { url: string; size: number; @@ -267,7 +285,7 @@ export const upload = async ( releaser: Releaser, url: string, path: string, - currentAssets: Array<{ id: number; name: string }>, + currentAssets: Array<{ id: number; name: string; label?: string | null }>, ): Promise => { const [owner, repo] = config.github_repository.split('/'); const { name, mime, size } = asset(path); @@ -275,7 +293,8 @@ export const upload = async ( // note: GitHub renames asset filenames that have special characters, non-alphanumeric characters, and leading or trailing periods. The "List release assets" endpoint lists the renamed filenames. // due to this renaming we need to be mindful when we compare the file name we're uploading with a name github may already have rewritten for logical comparison // see https://docs.github.com/en/rest/releases/assets?apiVersion=2022-11-28#upload-a-release-asset - ({ name: currentName }) => currentName == alignAssetName(name), + ({ name: currentName, label: currentLabel }) => + currentName === name || currentName === alignAssetName(name) || currentLabel === name, ); if (currentAsset) { if (config.input_overwrite_files === false) { @@ -310,6 +329,22 @@ export const upload = async ( }\n${json.message}\n${JSON.stringify(json.errors)}`, ); } + if (json.name && json.name !== name && json.id) { + console.log(`✏️ Restoring asset label to ${name}...`); + try { + const { data } = await releaser.updateReleaseAsset({ + owner, + repo, + asset_id: json.id, + name: json.name, + label: name, + }); + console.log(`✅ Uploaded ${name}`); + return data; + } catch (error) { + console.warn(`error updating release asset label for ${name}: ${error}`); + } + } console.log(`✅ Uploaded ${name}`); return json; } finally {