fix: restore dotfile asset labels (#749)

Signed-off-by: Rui Chen <rui@chenrui.dev>
This commit is contained in:
Rui Chen
2026-03-14 21:14:27 -04:00
committed by GitHub
parent ef43a3125e
commit 4aadb0df8b
3 changed files with 169 additions and 7 deletions

View File

@@ -6,8 +6,12 @@ import {
release, release,
Release, Release,
Releaser, Releaser,
upload,
} from '../src/github'; } 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'; import { assert, describe, expect, it, vi } from 'vitest';
describe('github', () => { describe('github', () => {
@@ -78,6 +82,7 @@ describe('github', () => {
listReleaseAssets: () => Promise.reject('Not implemented'), listReleaseAssets: () => Promise.reject('Not implemented'),
deleteReleaseAsset: () => Promise.reject('Not implemented'), deleteReleaseAsset: () => Promise.reject('Not implemented'),
deleteRelease: () => Promise.reject('Not implemented'), deleteRelease: () => Promise.reject('Not implemented'),
updateReleaseAsset: () => Promise.reject('Not implemented'),
uploadReleaseAsset: () => Promise.reject('Not implemented'), uploadReleaseAsset: () => Promise.reject('Not implemented'),
} as const; } as const;
@@ -201,6 +206,7 @@ describe('github', () => {
listReleaseAssets: () => Promise.reject('Not implemented'), listReleaseAssets: () => Promise.reject('Not implemented'),
deleteReleaseAsset: () => Promise.reject('Not implemented'), deleteReleaseAsset: () => Promise.reject('Not implemented'),
deleteRelease: () => Promise.reject('Not implemented'), deleteRelease: () => Promise.reject('Not implemented'),
updateReleaseAsset: () => Promise.reject('Not implemented'),
uploadReleaseAsset: () => Promise.reject('Not implemented'), uploadReleaseAsset: () => Promise.reject('Not implemented'),
}; };
@@ -258,6 +264,7 @@ describe('github', () => {
listReleaseAssets: () => Promise.reject('Not implemented'), listReleaseAssets: () => Promise.reject('Not implemented'),
deleteReleaseAsset: () => Promise.reject('Not implemented'), deleteReleaseAsset: () => Promise.reject('Not implemented'),
deleteRelease: () => Promise.reject('Not implemented'), deleteRelease: () => Promise.reject('Not implemented'),
updateReleaseAsset: () => Promise.reject('Not implemented'),
uploadReleaseAsset: () => Promise.reject('Not implemented'), uploadReleaseAsset: () => Promise.reject('Not implemented'),
} as const; } as const;
@@ -377,6 +384,7 @@ describe('github', () => {
listReleaseAssets: () => Promise.reject('Not implemented'), listReleaseAssets: () => Promise.reject('Not implemented'),
deleteReleaseAsset: () => Promise.reject('Not implemented'), deleteReleaseAsset: () => Promise.reject('Not implemented'),
deleteRelease: deleteReleaseSpy, deleteRelease: deleteReleaseSpy,
updateReleaseAsset: () => Promise.reject('Not implemented'),
uploadReleaseAsset: () => Promise.reject('Not implemented'), uploadReleaseAsset: () => Promise.reject('Not implemented'),
}; };
@@ -428,6 +436,7 @@ describe('github', () => {
listReleaseAssets: () => Promise.reject('Not implemented'), listReleaseAssets: () => Promise.reject('Not implemented'),
deleteReleaseAsset: () => Promise.reject('Not implemented'), deleteReleaseAsset: () => Promise.reject('Not implemented'),
deleteRelease: deleteReleaseSpy, deleteRelease: deleteReleaseSpy,
updateReleaseAsset: () => Promise.reject('Not implemented'),
uploadReleaseAsset: () => 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 });
}
});
});
}); });

4
dist/index.js vendored

File diff suppressed because one or more lines are too long

View File

@@ -23,7 +23,7 @@ export interface Release {
target_commitish: string; target_commitish: string;
draft: boolean; draft: boolean;
prerelease: boolean; prerelease: boolean;
assets: Array<{ id: number; name: string }>; assets: Array<{ id: number; name: string; label?: string | null }>;
} }
export interface Releaser { export interface Releaser {
@@ -71,12 +71,20 @@ export interface Releaser {
owner: string; owner: string;
repo: string; repo: string;
release_id: number; release_id: number;
}): Promise<Array<{ id: number; name: string; [key: string]: any }>>; }): Promise<Array<{ id: number; name: string; label?: string | null; [key: string]: any }>>;
deleteReleaseAsset(params: { owner: string; repo: string; asset_id: number }): Promise<void>; deleteReleaseAsset(params: { owner: string; repo: string; asset_id: number }): Promise<void>;
deleteRelease(params: { owner: string; repo: string; release_id: number }): Promise<void>; deleteRelease(params: { owner: string; repo: string; release_id: number }): Promise<void>;
updateReleaseAsset(params: {
owner: string;
repo: string;
asset_id: number;
name: string;
label: string;
}): Promise<{ data: any }>;
uploadReleaseAsset(params: { uploadReleaseAsset(params: {
url: string; url: string;
size: number; size: number;
@@ -211,7 +219,7 @@ export class GitHubReleaser implements Releaser {
owner: string; owner: string;
repo: string; repo: string;
release_id: number; release_id: number;
}): Promise<Array<{ id: number; name: string; [key: string]: any }>> { }): Promise<Array<{ id: number; name: string; label?: string | null; [key: string]: any }>> {
return this.github.paginate(this.github.rest.repos.listReleaseAssets, { return this.github.paginate(this.github.rest.repos.listReleaseAssets, {
...params, ...params,
per_page: 100, per_page: 100,
@@ -230,6 +238,16 @@ export class GitHubReleaser implements Releaser {
await this.github.rest.repos.deleteRelease(params); 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: { async uploadReleaseAsset(params: {
url: string; url: string;
size: number; size: number;
@@ -267,7 +285,7 @@ export const upload = async (
releaser: Releaser, releaser: Releaser,
url: string, url: string,
path: string, path: string,
currentAssets: Array<{ id: number; name: string }>, currentAssets: Array<{ id: number; name: string; label?: string | null }>,
): Promise<any> => { ): Promise<any> => {
const [owner, repo] = config.github_repository.split('/'); const [owner, repo] = config.github_repository.split('/');
const { name, mime, size } = asset(path); 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. // 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 // 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 // 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 (currentAsset) {
if (config.input_overwrite_files === false) { if (config.input_overwrite_files === false) {
@@ -310,6 +329,22 @@ export const upload = async (
}\n${json.message}\n${JSON.stringify(json.errors)}`, }\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}`); console.log(`✅ Uploaded ${name}`);
return json; return json;
} finally { } finally {