mirror of
https://gh-proxy.org/https://github.com/softprops/action-gh-release.git
synced 2026-07-15 23:13:02 +08:00
Compare commits
12 Commits
v3.0.1
...
7e13ed4ac5
| Author | SHA1 | Date | |
|---|---|---|---|
| 7e13ed4ac5 | |||
| e6c70a53cf | |||
| f345337888 | |||
| d8a89a2066 | |||
| 45ece40c31 | |||
| f6b913c3f9 | |||
| 15f193d7d8 | |||
| cc8268d46a | |||
| fd0ed1e85b | |||
| 132dbbba49 | |||
| 3743f4e7d1 | |||
| 566ec910ab |
@@ -13,7 +13,7 @@ jobs:
|
||||
build:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v5
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
@@ -22,6 +22,8 @@ jobs:
|
||||
|
||||
- name: Install
|
||||
run: npm ci
|
||||
- name: Typecheck
|
||||
run: npm run typecheck
|
||||
- name: Build
|
||||
run: npm run build
|
||||
- name: Check dist freshness
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
nodejs 24.14.1
|
||||
nodejs 24.18.0
|
||||
|
||||
@@ -15,7 +15,7 @@ Optimize for stability, reproducibility, and clear user value over broad rewrite
|
||||
|
||||
## Current Architecture
|
||||
|
||||
- `src/main.ts` is the orchestration layer: parse config, validate inputs, create/update release, upload assets, finalize, set outputs.
|
||||
- `src/main.ts` is the action bootstrap; `src/run.ts` orchestrates config parsing, validation, release creation/update, asset uploads, finalization, and outputs.
|
||||
- `src/github.ts` owns release semantics: lookup, create/update/finalize, asset upload, race handling, and GitHub API interaction.
|
||||
- `src/util.ts` owns parsing and path normalization.
|
||||
- Keep behavior-specific logic in `src/github.ts` or `src/util.ts`; avoid growing `src/main.ts` with ad-hoc feature branches.
|
||||
|
||||
+644
-27
@@ -3,6 +3,7 @@ import {
|
||||
findTagFromReleases,
|
||||
finalizeRelease,
|
||||
GitHubReleaser,
|
||||
listReleaseAssets,
|
||||
mimeOrDefault,
|
||||
release,
|
||||
Release,
|
||||
@@ -13,9 +14,33 @@ import {
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import { assert, describe, expect, it, vi } from 'vitest';
|
||||
import { afterEach, assert, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const unexpected = (method: string) =>
|
||||
vi.fn().mockRejectedValue(new Error(`Unexpected ${method} call`));
|
||||
|
||||
const createReleaser = (overrides: Partial<Releaser> = {}): Releaser => ({
|
||||
getReleaseByTag: unexpected('getReleaseByTag'),
|
||||
createRelease: unexpected('createRelease'),
|
||||
updateRelease: unexpected('updateRelease'),
|
||||
finalizeRelease: unexpected('finalizeRelease'),
|
||||
allReleases: async function* () {
|
||||
throw new Error('Unexpected allReleases call');
|
||||
},
|
||||
listReleaseAssets: unexpected('listReleaseAssets'),
|
||||
deleteReleaseAsset: unexpected('deleteReleaseAsset'),
|
||||
deleteRelease: unexpected('deleteRelease'),
|
||||
updateReleaseAsset: unexpected('updateReleaseAsset'),
|
||||
uploadReleaseAsset: unexpected('uploadReleaseAsset'),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('github', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const config = {
|
||||
github_token: 'test-token',
|
||||
github_ref: 'refs/tags/v1.0.0',
|
||||
@@ -105,6 +130,92 @@ describe('github', () => {
|
||||
assert.deepStrictEqual(result, targetRelease);
|
||||
});
|
||||
|
||||
it('falls back to paginated releases when the direct lookup cannot see a draft', async () => {
|
||||
const draftRelease = { ...mockRelease, draft: true };
|
||||
const pageAfterMatch = vi.fn();
|
||||
const releaser = {
|
||||
...mockReleaser,
|
||||
allReleases: async function* () {
|
||||
yield { data: [{ ...mockRelease, tag_name: 'other' }] };
|
||||
yield { data: [draftRelease] };
|
||||
pageAfterMatch();
|
||||
yield { data: [] };
|
||||
},
|
||||
};
|
||||
|
||||
const result = await findTagFromReleases(releaser, owner, repo, draftRelease.tag_name);
|
||||
|
||||
assert.strictEqual(result, draftRelease);
|
||||
expect(pageAfterMatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not exhaust pagination while checking a brand-new tag', async () => {
|
||||
let pagesRead = 0;
|
||||
const releaser = {
|
||||
...mockReleaser,
|
||||
allReleases: async function* () {
|
||||
pagesRead += 1;
|
||||
yield { data: [] };
|
||||
pagesRead += 1;
|
||||
yield { data: [] };
|
||||
throw new Error('Only the first 10000 results are available');
|
||||
},
|
||||
};
|
||||
|
||||
const result = await findTagFromReleases(releaser, owner, repo, 'brand-new-tag');
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(pagesRead).toBe(2);
|
||||
});
|
||||
|
||||
it('selects the canonical draft without deleting a pre-existing duplicate', async () => {
|
||||
const canonicalDraft: Release = {
|
||||
...mockRelease,
|
||||
id: 1,
|
||||
draft: true,
|
||||
assets: [{ id: 99, name: 'existing.zip' }],
|
||||
};
|
||||
const duplicateDraft: Release = {
|
||||
...canonicalDraft,
|
||||
id: 2,
|
||||
name: 'manually authored draft',
|
||||
body: 'notes that must not be deleted',
|
||||
assets: [],
|
||||
};
|
||||
const deleteRelease = vi.fn().mockResolvedValue(undefined);
|
||||
const releaser = {
|
||||
...mockReleaser,
|
||||
allReleases: async function* () {
|
||||
yield { data: [duplicateDraft, canonicalDraft] };
|
||||
},
|
||||
deleteRelease,
|
||||
};
|
||||
|
||||
const result = await findTagFromReleases(releaser, owner, repo, canonicalDraft.tag_name);
|
||||
|
||||
expect(result).toBe(canonicalDraft);
|
||||
expect(deleteRelease).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('retries draft discovery when GitHub release listing is briefly stale', async () => {
|
||||
vi.useFakeTimers();
|
||||
const draftRelease = { ...mockRelease, draft: true };
|
||||
let listingAttempt = 0;
|
||||
const releaser = {
|
||||
...mockReleaser,
|
||||
allReleases: async function* () {
|
||||
listingAttempt += 1;
|
||||
yield { data: listingAttempt === 1 ? [] : [draftRelease] };
|
||||
},
|
||||
};
|
||||
|
||||
const resultPromise = findTagFromReleases(releaser, owner, repo, draftRelease.tag_name, 2);
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
|
||||
await expect(resultPromise).resolves.toBe(draftRelease);
|
||||
expect(listingAttempt).toBe(2);
|
||||
});
|
||||
|
||||
it('returns undefined when release is not found (404)', async () => {
|
||||
const releaser = {
|
||||
...mockReleaser,
|
||||
@@ -130,6 +241,18 @@ describe('github', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('re-throws errors from the draft listing fallback', async () => {
|
||||
const listingError = new Error('release listing failed');
|
||||
const releaser = {
|
||||
...mockReleaser,
|
||||
allReleases: async function* () {
|
||||
throw listingError;
|
||||
},
|
||||
};
|
||||
|
||||
await expect(findTagFromReleases(releaser, owner, repo, 'v1.0.0')).rejects.toBe(listingError);
|
||||
});
|
||||
|
||||
it('finds a release with empty tag name', async () => {
|
||||
const emptyTag = '';
|
||||
const targetRelease = {
|
||||
@@ -148,7 +271,158 @@ describe('github', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('listReleaseAssets', () => {
|
||||
const releaseToInspect: Release = {
|
||||
id: 42,
|
||||
upload_url: 'https://uploads.example.test/releases/42/assets',
|
||||
html_url: 'https://example.test/releases/42',
|
||||
tag_name: 'v1.0.0',
|
||||
name: 'v1.0.0',
|
||||
target_commitish: 'main',
|
||||
draft: false,
|
||||
prerelease: false,
|
||||
assets: [],
|
||||
};
|
||||
|
||||
it('returns assets with the repository and release parameters', async () => {
|
||||
const assets = [{ id: 7, name: 'action.zip' }];
|
||||
const listReleaseAssetsSpy = vi.fn().mockResolvedValue(assets);
|
||||
const releaser = createReleaser({ listReleaseAssets: listReleaseAssetsSpy });
|
||||
|
||||
await expect(listReleaseAssets(config, releaser, releaseToInspect)).resolves.toBe(assets);
|
||||
expect(listReleaseAssetsSpy).toHaveBeenCalledOnce();
|
||||
expect(listReleaseAssetsSpy).toHaveBeenCalledWith({
|
||||
owner: 'owner',
|
||||
repo: 'repo',
|
||||
release_id: 42,
|
||||
});
|
||||
});
|
||||
|
||||
it('retries a transient failure and then succeeds', async () => {
|
||||
const assets = [{ id: 7, name: 'action.zip' }];
|
||||
const listReleaseAssetsSpy = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('temporary failure'))
|
||||
.mockResolvedValueOnce(assets);
|
||||
const releaser = createReleaser({ listReleaseAssets: listReleaseAssetsSpy });
|
||||
|
||||
await expect(listReleaseAssets(config, releaser, releaseToInspect, 2)).resolves.toBe(assets);
|
||||
expect(listReleaseAssetsSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('stops after exhausting the configured attempts', async () => {
|
||||
const listReleaseAssetsSpy = vi.fn().mockRejectedValue(new Error('persistent failure'));
|
||||
const releaser = createReleaser({ listReleaseAssets: listReleaseAssetsSpy });
|
||||
|
||||
await expect(listReleaseAssets(config, releaser, releaseToInspect, 2)).rejects.toThrow(
|
||||
'Too many retries.',
|
||||
);
|
||||
expect(listReleaseAssetsSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GitHubReleaser', () => {
|
||||
it('uses GitHub standard asset deletion without adding the release ID', async () => {
|
||||
const deleteReleaseAsset = vi.fn().mockResolvedValue(undefined);
|
||||
const request = vi.fn();
|
||||
const releaser = new GitHubReleaser({
|
||||
rest: { repos: { deleteReleaseAsset } },
|
||||
request,
|
||||
} as any);
|
||||
|
||||
await releaser.deleteReleaseAsset({
|
||||
owner: 'owner',
|
||||
repo: 'repo',
|
||||
release_id: 42,
|
||||
asset_id: 99,
|
||||
});
|
||||
|
||||
expect(deleteReleaseAsset).toHaveBeenCalledOnce();
|
||||
expect(deleteReleaseAsset).toHaveBeenCalledWith({
|
||||
owner: 'owner',
|
||||
repo: 'repo',
|
||||
asset_id: 99,
|
||||
});
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back to release-scoped asset deletion after a standard 404', async () => {
|
||||
const deleteReleaseAsset = vi.fn().mockRejectedValue({
|
||||
status: 404,
|
||||
message: 'standard route not found',
|
||||
});
|
||||
const request = vi.fn().mockResolvedValue({ status: 204 });
|
||||
const releaser = new GitHubReleaser({
|
||||
rest: { repos: { deleteReleaseAsset } },
|
||||
request,
|
||||
} as any);
|
||||
|
||||
await releaser.deleteReleaseAsset({
|
||||
owner: 'owner',
|
||||
repo: 'repo',
|
||||
release_id: 42,
|
||||
asset_id: 99,
|
||||
});
|
||||
|
||||
expect(request).toHaveBeenCalledOnce();
|
||||
expect(request).toHaveBeenCalledWith(
|
||||
'DELETE /repos/{owner}/{repo}/releases/{release_id}/assets/{asset_id}',
|
||||
{
|
||||
owner: 'owner',
|
||||
repo: 'repo',
|
||||
release_id: 42,
|
||||
asset_id: 99,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('does not mask non-404 asset deletion failures', async () => {
|
||||
const deletionError = { status: 403, message: 'forbidden' };
|
||||
const request = vi.fn();
|
||||
const releaser = new GitHubReleaser({
|
||||
rest: {
|
||||
repos: { deleteReleaseAsset: vi.fn().mockRejectedValue(deletionError) },
|
||||
},
|
||||
request,
|
||||
} as any);
|
||||
|
||||
await expect(
|
||||
releaser.deleteReleaseAsset({
|
||||
owner: 'owner',
|
||||
repo: 'repo',
|
||||
release_id: 42,
|
||||
asset_id: 99,
|
||||
}),
|
||||
).rejects.toBe(deletionError);
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports both failures when release-scoped deletion also fails', async () => {
|
||||
const releaser = new GitHubReleaser({
|
||||
rest: {
|
||||
repos: {
|
||||
deleteReleaseAsset: vi
|
||||
.fn()
|
||||
.mockRejectedValue({ status: 404, message: 'standard route not found' }),
|
||||
},
|
||||
},
|
||||
request: vi
|
||||
.fn()
|
||||
.mockRejectedValue({ status: 404, message: 'release-scoped route not found' }),
|
||||
} as any);
|
||||
|
||||
await expect(
|
||||
releaser.deleteReleaseAsset({
|
||||
owner: 'owner',
|
||||
repo: 'repo',
|
||||
release_id: 42,
|
||||
asset_id: 99,
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
'Failed to delete release asset 99. GitHub endpoint: standard route not found; release-scoped endpoint: release-scoped route not found',
|
||||
);
|
||||
});
|
||||
|
||||
it('passes previous_tag_name to generateReleaseNotes and strips it from createRelease', async () => {
|
||||
const generateReleaseNotes = vi.fn(async () => ({
|
||||
data: {
|
||||
@@ -519,6 +793,130 @@ describe('github', () => {
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it.each([
|
||||
{
|
||||
name: 'a remote repository without a discussion category',
|
||||
repository: 'remote-owner/release-repo',
|
||||
category: undefined,
|
||||
},
|
||||
{
|
||||
name: 'the current repository without a discussion category',
|
||||
repository: 'owner/repo',
|
||||
category: undefined,
|
||||
},
|
||||
{
|
||||
name: 'a repository with a discussion category',
|
||||
repository: 'owner/repo',
|
||||
category: 'Announcements',
|
||||
},
|
||||
])('classifies a create-release 404 for $name without retrying', async (testCase) => {
|
||||
const releaseError = {
|
||||
status: 404,
|
||||
message: 'Not Found - create-a-release',
|
||||
};
|
||||
const log = vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
||||
const createRelease = vi.fn().mockRejectedValue(releaseError);
|
||||
const releaser = createReleaser({
|
||||
getReleaseByTag: vi.fn().mockRejectedValue({ status: 404 }),
|
||||
createRelease,
|
||||
allReleases: async function* () {
|
||||
yield { data: [] };
|
||||
},
|
||||
});
|
||||
|
||||
const thrown = await release(
|
||||
{
|
||||
...config,
|
||||
github_repository: testCase.repository,
|
||||
input_discussion_category_name: testCase.category,
|
||||
},
|
||||
releaser,
|
||||
1,
|
||||
).catch((error) => error);
|
||||
|
||||
expect(thrown).toMatchObject({
|
||||
name: 'ReleaseCreationError',
|
||||
status: 404,
|
||||
cause: releaseError,
|
||||
});
|
||||
expect(thrown.message).toContain(
|
||||
`GitHub returned 404 while creating the release. Verify that ${testCase.repository} exists under the expected owner`,
|
||||
);
|
||||
expect(thrown.message).toContain('the token can access it');
|
||||
expect(thrown.message).toContain('fine-grained PAT');
|
||||
expect(thrown.message).toContain('Contents: write');
|
||||
expect(thrown.message).toContain('GitHub response: Not Found - create-a-release');
|
||||
if (testCase.category) {
|
||||
expect(thrown.message).toContain('Discussions and the requested category "Announcements"');
|
||||
} else {
|
||||
expect(thrown.message).not.toContain('discussion category mismatch');
|
||||
expect(thrown.message).not.toContain('requested category');
|
||||
}
|
||||
expect(createRelease).toHaveBeenCalledOnce();
|
||||
expect(log).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining('Unexpected error fetching GitHub release'),
|
||||
);
|
||||
});
|
||||
|
||||
it('classifies a draft-listing 404 as a repository access failure', async () => {
|
||||
const listingError = {
|
||||
status: 404,
|
||||
message: 'Not Found - list-releases',
|
||||
};
|
||||
const createRelease = vi.fn();
|
||||
const releaser = createReleaser({
|
||||
getReleaseByTag: vi.fn().mockRejectedValue({ status: 404 }),
|
||||
allReleases: async function* () {
|
||||
throw listingError;
|
||||
},
|
||||
createRelease,
|
||||
});
|
||||
|
||||
const thrown = await release(
|
||||
{
|
||||
...config,
|
||||
github_repository: 'remote-owner/release-repo',
|
||||
input_discussion_category_name: undefined,
|
||||
},
|
||||
releaser,
|
||||
1,
|
||||
).catch((error) => error);
|
||||
|
||||
expect(thrown).toMatchObject({
|
||||
name: 'ReleaseAccessError',
|
||||
status: 404,
|
||||
cause: listingError,
|
||||
});
|
||||
expect(thrown.message).toContain('GitHub returned 404 while checking existing releases');
|
||||
expect(thrown.message).toContain('remote-owner/release-repo');
|
||||
expect(thrown.message).toContain('the token can access it');
|
||||
expect(thrown.message).toContain('fine-grained PAT');
|
||||
expect(thrown.message).toContain('Contents: write');
|
||||
expect(thrown.message).toContain('GitHub response: Not Found - list-releases');
|
||||
expect(thrown.message).not.toContain('discussion category mismatch');
|
||||
expect(createRelease).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports a useful create error without assuming response data exists', async () => {
|
||||
const releaseError = {
|
||||
status: 403,
|
||||
message: 'Resource not accessible by integration',
|
||||
};
|
||||
const log = vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
||||
const releaser = createReleaser({
|
||||
getReleaseByTag: vi.fn().mockRejectedValue({ status: 404 }),
|
||||
createRelease: vi.fn().mockRejectedValue(releaseError),
|
||||
allReleases: async function* () {
|
||||
yield { data: [] };
|
||||
},
|
||||
});
|
||||
|
||||
await expect(release(config, releaser, 1)).rejects.toBe(releaseError);
|
||||
expect(log).toHaveBeenCalledWith('⚠️ GitHub release failed with status: 403');
|
||||
expect(log).toHaveBeenCalledWith('Resource not accessible by integration');
|
||||
expect(log).not.toHaveBeenCalledWith('undefined');
|
||||
});
|
||||
|
||||
it('passes previous_tag_name through when creating a release with generated notes', async () => {
|
||||
const createReleaseSpy = vi.fn(async () => ({
|
||||
data: {
|
||||
@@ -633,14 +1031,18 @@ describe('github', () => {
|
||||
assets: [],
|
||||
};
|
||||
|
||||
const createReleaseSpy = vi.fn(async () => ({ data: createdRelease }));
|
||||
let created = false;
|
||||
const createReleaseSpy = vi.fn(async () => {
|
||||
created = true;
|
||||
return { 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] };
|
||||
yield { data: created ? [createdRelease] : [] };
|
||||
},
|
||||
listReleaseAssets: () => Promise.reject('Not implemented'),
|
||||
deleteReleaseAsset: () => Promise.reject('Not implemented'),
|
||||
@@ -661,6 +1063,59 @@ describe('github', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['an omitted draft input', undefined],
|
||||
['a null-expression draft input', undefined],
|
||||
['an explicit false draft input', false],
|
||||
['an explicit true draft input', true],
|
||||
])('reuses an existing draft for %s', async (_name, input_draft) => {
|
||||
const existingDraft: Release = {
|
||||
id: 17,
|
||||
upload_url: 'draft-upload',
|
||||
html_url: 'draft-html',
|
||||
tag_name: 'v1.0.0',
|
||||
name: 'draft release',
|
||||
body: 'draft body',
|
||||
target_commitish: 'main',
|
||||
draft: true,
|
||||
prerelease: false,
|
||||
assets: [],
|
||||
};
|
||||
const updatedDraft = { ...existingDraft, prerelease: true };
|
||||
const updateRelease = vi.fn().mockResolvedValue({ data: updatedDraft });
|
||||
const createRelease = unexpected('createRelease');
|
||||
const deleteRelease = unexpected('deleteRelease');
|
||||
const releaser = createReleaser({
|
||||
getReleaseByTag: vi.fn().mockRejectedValue({ status: 404 }),
|
||||
allReleases: async function* () {
|
||||
yield { data: [existingDraft] };
|
||||
},
|
||||
createRelease,
|
||||
updateRelease,
|
||||
deleteRelease,
|
||||
});
|
||||
|
||||
const result = await release(
|
||||
{
|
||||
...config,
|
||||
input_draft,
|
||||
input_prerelease: true,
|
||||
},
|
||||
releaser,
|
||||
);
|
||||
|
||||
expect(result).toEqual({ release: updatedDraft, created: false });
|
||||
expect(updateRelease).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
release_id: existingDraft.id,
|
||||
draft: true,
|
||||
prerelease: true,
|
||||
}),
|
||||
);
|
||||
expect(createRelease).not.toHaveBeenCalled();
|
||||
expect(deleteRelease).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('retries upload after deleting conflicting asset on 422 already_exists race', async () => {
|
||||
const uploadReleaseAsset = vi
|
||||
.fn()
|
||||
@@ -695,6 +1150,7 @@ describe('github', () => {
|
||||
'https://uploads.github.com/repos/owner/repo/releases/1/assets',
|
||||
'__tests__/release.txt',
|
||||
[],
|
||||
1,
|
||||
);
|
||||
|
||||
expect(result).toStrictEqual({ id: 123, name: 'release.txt' });
|
||||
@@ -706,11 +1162,52 @@ describe('github', () => {
|
||||
expect(deleteReleaseAsset).toHaveBeenCalledWith({
|
||||
owner: 'owner',
|
||||
repo: 'repo',
|
||||
release_id: 1,
|
||||
asset_id: 99,
|
||||
});
|
||||
expect(uploadReleaseAsset).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('does not upload until deletion of an existing asset succeeds', async () => {
|
||||
const deletionError = new Error('asset deletion failed');
|
||||
const uploadReleaseAsset = vi.fn();
|
||||
const releaser = createReleaser({
|
||||
deleteReleaseAsset: vi.fn().mockRejectedValue(deletionError),
|
||||
uploadReleaseAsset,
|
||||
});
|
||||
|
||||
await expect(
|
||||
upload(
|
||||
config,
|
||||
releaser,
|
||||
'https://uploads.github.com/repos/owner/repo/releases/1/assets',
|
||||
'__tests__/release.txt',
|
||||
[{ id: 99, name: 'release.txt' }],
|
||||
1,
|
||||
),
|
||||
).rejects.toBe(deletionError);
|
||||
expect(uploadReleaseAsset).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips deletion and upload when overwrite_files is false', async () => {
|
||||
const deleteReleaseAsset = vi.fn();
|
||||
const uploadReleaseAsset = vi.fn();
|
||||
const releaser = createReleaser({ deleteReleaseAsset, uploadReleaseAsset });
|
||||
|
||||
await expect(
|
||||
upload(
|
||||
{ ...config, input_overwrite_files: false },
|
||||
releaser,
|
||||
'https://uploads.github.com/repos/owner/repo/releases/1/assets',
|
||||
'__tests__/release.txt',
|
||||
[{ id: 99, name: 'release.txt' }],
|
||||
1,
|
||||
),
|
||||
).resolves.toBeNull();
|
||||
expect(deleteReleaseAsset).not.toHaveBeenCalled();
|
||||
expect(uploadReleaseAsset).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('surfaces an actionable immutable-release error for prerelease uploads', async () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), 'gh-release-immutable-'));
|
||||
const assetPath = join(tempDir, 'draft-false.txt');
|
||||
@@ -750,6 +1247,7 @@ describe('github', () => {
|
||||
'https://uploads.github.com/repos/owner/repo/releases/1/assets',
|
||||
assetPath,
|
||||
[],
|
||||
1,
|
||||
),
|
||||
).rejects.toThrow(
|
||||
'Cannot upload asset draft-false.txt to an immutable release. GitHub only allows asset uploads before a release is published, but draft prereleases publish with the release.published event instead of release.prereleased.',
|
||||
@@ -804,6 +1302,7 @@ describe('github', () => {
|
||||
'https://uploads.github.com/repos/owner/repo/releases/1/assets',
|
||||
dotfilePath,
|
||||
[],
|
||||
1,
|
||||
);
|
||||
|
||||
expect(result).toStrictEqual({ id: 123, name: 'default.config', label: '.config' });
|
||||
@@ -815,6 +1314,7 @@ describe('github', () => {
|
||||
expect(deleteReleaseAsset).toHaveBeenCalledWith({
|
||||
owner: 'owner',
|
||||
repo: 'repo',
|
||||
release_id: 1,
|
||||
asset_id: 99,
|
||||
});
|
||||
expect(updateReleaseAsset).toHaveBeenCalledWith({
|
||||
@@ -831,6 +1331,7 @@ describe('github', () => {
|
||||
});
|
||||
|
||||
it('handles 422 already_exists error gracefully', async () => {
|
||||
vi.useFakeTimers();
|
||||
const existingRelease = {
|
||||
id: 1,
|
||||
upload_url: 'test',
|
||||
@@ -877,7 +1378,7 @@ describe('github', () => {
|
||||
}),
|
||||
finalizeRelease: () => Promise.reject('Not implemented'),
|
||||
allReleases: async function* () {
|
||||
yield { data: [existingRelease] };
|
||||
yield { data: createAttempts > 0 ? [existingRelease] : [] };
|
||||
},
|
||||
listReleaseAssets: () => Promise.reject('Not implemented'),
|
||||
deleteReleaseAsset: () => Promise.reject('Not implemented'),
|
||||
@@ -885,7 +1386,9 @@ describe('github', () => {
|
||||
uploadReleaseAsset: () => Promise.reject('Not implemented'),
|
||||
} as const;
|
||||
|
||||
const result = await release(config, mockReleaser, 2);
|
||||
const resultPromise = release(config, mockReleaser, 2);
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
const result = await resultPromise;
|
||||
assert.ok(result);
|
||||
assert.equal(result.release.id, 1);
|
||||
assert.equal(result.created, false);
|
||||
@@ -979,11 +1482,14 @@ describe('github', () => {
|
||||
}
|
||||
return Promise.resolve({ data: canonicalRelease });
|
||||
},
|
||||
createRelease: () => Promise.resolve({ data: duplicateRelease }),
|
||||
createRelease: () => {
|
||||
lookupCount += 1;
|
||||
return Promise.resolve({ data: duplicateRelease });
|
||||
},
|
||||
updateRelease: () => Promise.reject('Not implemented'),
|
||||
finalizeRelease: () => Promise.reject('Not implemented'),
|
||||
allReleases: async function* () {
|
||||
yield { data: [duplicateRelease, canonicalRelease] };
|
||||
yield { data: lookupCount > 1 ? [duplicateRelease, canonicalRelease] : [] };
|
||||
},
|
||||
listReleaseAssets: () => Promise.reject('Not implemented'),
|
||||
deleteReleaseAsset: () => Promise.reject('Not implemented'),
|
||||
@@ -992,7 +1498,7 @@ describe('github', () => {
|
||||
uploadReleaseAsset: () => Promise.reject('Not implemented'),
|
||||
};
|
||||
|
||||
const result = await release(config, mockReleaser, 2);
|
||||
const result = await release(config, mockReleaser, 1);
|
||||
|
||||
assert.equal(result.release.id, canonicalRelease.id);
|
||||
assert.equal(result.created, false);
|
||||
@@ -1003,6 +1509,47 @@ describe('github', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('does not delete a pre-existing draft while canonicalizing its own create', async () => {
|
||||
const createdRelease: Release = {
|
||||
id: 1,
|
||||
upload_url: 'created-upload',
|
||||
html_url: 'created-html',
|
||||
tag_name: 'v1.0.0',
|
||||
name: 'created release',
|
||||
body: 'created body',
|
||||
target_commitish: 'main',
|
||||
draft: true,
|
||||
prerelease: false,
|
||||
assets: [],
|
||||
};
|
||||
const manualDraft: Release = {
|
||||
...createdRelease,
|
||||
id: 2,
|
||||
name: 'manually authored draft',
|
||||
body: 'notes that must not be deleted',
|
||||
};
|
||||
let created = false;
|
||||
const deleteRelease = vi.fn().mockResolvedValue(undefined);
|
||||
const releaser = createReleaser({
|
||||
getReleaseByTag: vi.fn(() =>
|
||||
created ? Promise.resolve({ data: createdRelease }) : Promise.reject({ status: 404 }),
|
||||
),
|
||||
createRelease: vi.fn(async () => {
|
||||
created = true;
|
||||
return { data: createdRelease };
|
||||
}),
|
||||
allReleases: async function* () {
|
||||
yield { data: created ? [createdRelease, manualDraft] : [] };
|
||||
},
|
||||
deleteRelease,
|
||||
});
|
||||
|
||||
const result = await release(config, releaser, 1);
|
||||
|
||||
expect(result).toEqual({ release: createdRelease, created: true });
|
||||
expect(deleteRelease).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back to recent releases when tag lookup still lags after create', async () => {
|
||||
const canonicalRelease: Release = {
|
||||
id: 1,
|
||||
@@ -1029,14 +1576,18 @@ describe('github', () => {
|
||||
assets: [],
|
||||
};
|
||||
|
||||
let created = false;
|
||||
const deleteReleaseSpy = vi.fn(async () => undefined);
|
||||
const mockReleaser: Releaser = {
|
||||
getReleaseByTag: () => Promise.reject({ status: 404 }),
|
||||
createRelease: () => Promise.resolve({ data: duplicateRelease }),
|
||||
createRelease: () => {
|
||||
created = true;
|
||||
return Promise.resolve({ data: duplicateRelease });
|
||||
},
|
||||
updateRelease: () => Promise.reject('Not implemented'),
|
||||
finalizeRelease: () => Promise.reject('Not implemented'),
|
||||
allReleases: async function* () {
|
||||
yield { data: [duplicateRelease, canonicalRelease] };
|
||||
yield { data: created ? [duplicateRelease, canonicalRelease] : [] };
|
||||
},
|
||||
listReleaseAssets: () => Promise.reject('Not implemented'),
|
||||
deleteReleaseAsset: () => Promise.reject('Not implemented'),
|
||||
@@ -1056,7 +1607,59 @@ describe('github', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('deletes the just-created duplicate draft even if recent release listing misses it', async () => {
|
||||
it.each([
|
||||
['gains assets', { assets: [{ id: 9, name: 'concurrent.zip' }] }],
|
||||
['is published', { draft: false }],
|
||||
])('does not delete its created release when it %s concurrently', async (_name, patch) => {
|
||||
const canonicalRelease: Release = {
|
||||
id: 1,
|
||||
upload_url: 'canonical-upload',
|
||||
html_url: 'canonical-html',
|
||||
tag_name: 'v1.0.0',
|
||||
name: 'canonical',
|
||||
body: 'test',
|
||||
target_commitish: 'main',
|
||||
draft: true,
|
||||
prerelease: false,
|
||||
assets: [],
|
||||
};
|
||||
const createdRelease: Release = {
|
||||
...canonicalRelease,
|
||||
id: 2,
|
||||
name: 'created duplicate',
|
||||
};
|
||||
const refreshedCreatedRelease: Release = { ...createdRelease, ...patch };
|
||||
const releaseByTag = refreshedCreatedRelease.draft
|
||||
? canonicalRelease
|
||||
: refreshedCreatedRelease;
|
||||
let created = false;
|
||||
const deleteRelease = vi.fn().mockResolvedValue(undefined);
|
||||
const releaser = createReleaser({
|
||||
getReleaseByTag: vi.fn(() =>
|
||||
created ? Promise.resolve({ data: releaseByTag }) : Promise.reject({ status: 404 }),
|
||||
),
|
||||
createRelease: vi.fn(async () => {
|
||||
created = true;
|
||||
return { data: createdRelease };
|
||||
}),
|
||||
allReleases: async function* () {
|
||||
yield {
|
||||
data: created ? [refreshedCreatedRelease, canonicalRelease] : [],
|
||||
};
|
||||
},
|
||||
deleteRelease,
|
||||
});
|
||||
|
||||
const result = await release(config, releaser, 1);
|
||||
|
||||
expect(result).toEqual({
|
||||
release: releaseByTag,
|
||||
created: releaseByTag.id === createdRelease.id,
|
||||
});
|
||||
expect(deleteRelease).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not delete the just-created duplicate when refreshed state is unavailable', async () => {
|
||||
const canonicalRelease: Release = {
|
||||
id: 1,
|
||||
upload_url: 'canonical-upload',
|
||||
@@ -1092,11 +1695,14 @@ describe('github', () => {
|
||||
}
|
||||
return Promise.resolve({ data: canonicalRelease });
|
||||
},
|
||||
createRelease: () => Promise.resolve({ data: duplicateRelease }),
|
||||
createRelease: () => {
|
||||
lookupCount += 1;
|
||||
return Promise.resolve({ data: duplicateRelease });
|
||||
},
|
||||
updateRelease: () => Promise.reject('Not implemented'),
|
||||
finalizeRelease: () => Promise.reject('Not implemented'),
|
||||
allReleases: async function* () {
|
||||
yield { data: [canonicalRelease] };
|
||||
yield { data: lookupCount > 1 ? [canonicalRelease] : [] };
|
||||
},
|
||||
listReleaseAssets: () => Promise.reject('Not implemented'),
|
||||
deleteReleaseAsset: () => Promise.reject('Not implemented'),
|
||||
@@ -1105,15 +1711,11 @@ describe('github', () => {
|
||||
uploadReleaseAsset: () => Promise.reject('Not implemented'),
|
||||
};
|
||||
|
||||
const result = await release(config, mockReleaser, 2);
|
||||
const result = await release(config, mockReleaser, 1);
|
||||
|
||||
assert.equal(result.release.id, canonicalRelease.id);
|
||||
assert.equal(result.created, false);
|
||||
expect(deleteReleaseSpy).toHaveBeenCalledWith({
|
||||
owner: 'owner',
|
||||
repo: 'repo',
|
||||
release_id: duplicateRelease.id,
|
||||
});
|
||||
expect(deleteReleaseSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1160,6 +1762,7 @@ describe('github', () => {
|
||||
'https://uploads.example.test/assets',
|
||||
dotfilePath,
|
||||
[],
|
||||
1,
|
||||
);
|
||||
|
||||
expect(updateReleaseAssetSpy).toHaveBeenCalledWith({
|
||||
@@ -1231,6 +1834,7 @@ describe('github', () => {
|
||||
'https://uploads.github.com/repos/owner/repo/releases/1/assets',
|
||||
dotfilePath,
|
||||
[],
|
||||
1,
|
||||
);
|
||||
|
||||
expect(updateReleaseAssetSpy).toHaveBeenNthCalledWith(1, {
|
||||
@@ -1301,6 +1905,7 @@ describe('github', () => {
|
||||
'https://uploads.github.com/repos/owner/repo/releases/1/assets',
|
||||
dotfilePath,
|
||||
[],
|
||||
1,
|
||||
);
|
||||
|
||||
expect(listReleaseAssetsSpy).toHaveBeenCalledWith({
|
||||
@@ -1360,6 +1965,7 @@ describe('github', () => {
|
||||
'https://uploads.github.com/repos/owner/repo/releases/1/assets',
|
||||
dotfilePath,
|
||||
[],
|
||||
1,
|
||||
);
|
||||
|
||||
expect(listReleaseAssetsSpy).toHaveBeenCalledWith({
|
||||
@@ -1378,6 +1984,7 @@ describe('github', () => {
|
||||
});
|
||||
|
||||
it('polls for a matching asset after update-a-release-asset 404 before failing', async () => {
|
||||
vi.useFakeTimers();
|
||||
const tempDir = mkdtempSync(join(tmpdir(), 'gh-release-dotfile-'));
|
||||
const dotfilePath = join(tempDir, '.config');
|
||||
writeFileSync(dotfilePath, 'config');
|
||||
@@ -1419,9 +2026,11 @@ describe('github', () => {
|
||||
'https://uploads.github.com/repos/owner/repo/releases/1/assets',
|
||||
dotfilePath,
|
||||
[],
|
||||
1,
|
||||
);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 1100));
|
||||
await vi.waitFor(() => expect(listReleaseAssetsSpy).toHaveBeenCalledTimes(1));
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
|
||||
const result = await resultPromise;
|
||||
|
||||
@@ -1473,18 +2082,26 @@ describe('github', () => {
|
||||
};
|
||||
|
||||
try {
|
||||
await upload(config, releaser, 'https://uploads.example.test/assets', dotfilePath, [
|
||||
{
|
||||
id: 1,
|
||||
name: 'default.config',
|
||||
label: '.config',
|
||||
},
|
||||
]);
|
||||
await upload(
|
||||
config,
|
||||
releaser,
|
||||
'https://uploads.example.test/assets',
|
||||
dotfilePath,
|
||||
[
|
||||
{
|
||||
id: 1,
|
||||
name: 'default.config',
|
||||
label: '.config',
|
||||
},
|
||||
],
|
||||
1,
|
||||
);
|
||||
|
||||
expect(deleteReleaseAssetSpy).toHaveBeenCalledWith({
|
||||
asset_id: 1,
|
||||
owner: 'owner',
|
||||
repo: 'repo',
|
||||
release_id: 1,
|
||||
});
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { expect, it, vi } from 'vitest';
|
||||
|
||||
const run = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
|
||||
|
||||
vi.mock('../src/run', () => ({ run }));
|
||||
|
||||
it('starts the action orchestration', async () => {
|
||||
await import('../src/main');
|
||||
|
||||
expect(run).toHaveBeenCalledOnce();
|
||||
});
|
||||
@@ -0,0 +1,182 @@
|
||||
import { getOctokit } from '@actions/github';
|
||||
import { createServer, type Server } from 'http';
|
||||
import { type AddressInfo } from 'net';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { GitHubReleaser, release, type Release } from '../src/github';
|
||||
import { parseConfig, type Config } from '../src/util';
|
||||
|
||||
type CapturedRequest = {
|
||||
path: string;
|
||||
authorization: string | undefined;
|
||||
contentType: string | undefined;
|
||||
body: Record<string, unknown>;
|
||||
};
|
||||
|
||||
const closeServer = async (server: Server): Promise<void> => {
|
||||
server.closeIdleConnections();
|
||||
server.closeAllConnections();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
};
|
||||
|
||||
describe('release creation transport', () => {
|
||||
let server: Server;
|
||||
let baseUrl: string;
|
||||
const requests: CapturedRequest[] = [];
|
||||
const releases = new Map<string, Release>();
|
||||
|
||||
beforeAll(async () => {
|
||||
server = createServer(async (request, response) => {
|
||||
const url = new URL(request.url || '/', 'http://127.0.0.1');
|
||||
const tagMatch = url.pathname.match(/^\/repos\/owner\/remote\/releases\/tags\/(.+)$/);
|
||||
if (request.method === 'GET' && tagMatch) {
|
||||
const release = releases.get(decodeURIComponent(tagMatch[1]));
|
||||
response.writeHead(release ? 200 : 404, { 'content-type': 'application/json' });
|
||||
response.end(JSON.stringify(release ?? { message: 'Not Found' }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === 'GET' && url.pathname === '/repos/owner/remote/releases') {
|
||||
response.writeHead(200, { 'content-type': 'application/json' });
|
||||
response.end(JSON.stringify([...releases.values()]));
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === 'POST' && url.pathname === '/repos/owner/remote/releases') {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of request) {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||
}
|
||||
const body = JSON.parse(Buffer.concat(chunks).toString('utf8')) as Record<string, unknown>;
|
||||
requests.push({
|
||||
path: url.pathname,
|
||||
authorization: request.headers.authorization,
|
||||
contentType: request.headers['content-type'],
|
||||
body,
|
||||
});
|
||||
const tag = String(body.tag_name);
|
||||
const createdRelease: Release = {
|
||||
id: requests.length,
|
||||
upload_url: `http://127.0.0.1/uploads/${requests.length}`,
|
||||
html_url: `http://127.0.0.1/releases/${requests.length}`,
|
||||
tag_name: tag,
|
||||
name: String(body.name),
|
||||
body: typeof body.body === 'string' ? body.body : null,
|
||||
target_commitish: 'main',
|
||||
draft: Boolean(body.draft),
|
||||
prerelease: Boolean(body.prerelease),
|
||||
assets: [],
|
||||
};
|
||||
releases.set(tag, createdRelease);
|
||||
response.writeHead(201, { 'content-type': 'application/json' });
|
||||
response.end(JSON.stringify(createdRelease));
|
||||
return;
|
||||
}
|
||||
|
||||
response.writeHead(500, { 'content-type': 'application/json' });
|
||||
response.end(
|
||||
JSON.stringify({ message: `Unexpected route ${request.method} ${url.pathname}` }),
|
||||
);
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once('error', reject);
|
||||
server.listen(0, '127.0.0.1', resolve);
|
||||
});
|
||||
const address = server.address() as AddressInfo;
|
||||
baseUrl = `http://127.0.0.1:${address.port}`;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await closeServer(server);
|
||||
});
|
||||
|
||||
it('serializes user-facing category inputs through the real Octokit request path', async () => {
|
||||
const parsedEmptyCategory = parseConfig({
|
||||
INPUT_DISCUSSION_CATEGORY_NAME: '',
|
||||
}).input_discussion_category_name;
|
||||
expect(parsedEmptyCategory).toBeUndefined();
|
||||
|
||||
const cases: Array<{
|
||||
name: string;
|
||||
categoryProperty: 'absent' | 'present';
|
||||
category: string | undefined;
|
||||
expectedCategory: string | undefined;
|
||||
}> = [
|
||||
{
|
||||
name: 'absent-category',
|
||||
categoryProperty: 'absent',
|
||||
category: undefined,
|
||||
expectedCategory: undefined,
|
||||
},
|
||||
{
|
||||
name: 'undefined-category',
|
||||
categoryProperty: 'present',
|
||||
category: undefined,
|
||||
expectedCategory: undefined,
|
||||
},
|
||||
{
|
||||
name: 'empty-input-category',
|
||||
categoryProperty: 'present',
|
||||
category: parsedEmptyCategory,
|
||||
expectedCategory: undefined,
|
||||
},
|
||||
{
|
||||
name: 'valid-category',
|
||||
categoryProperty: 'present',
|
||||
category: 'Announcements',
|
||||
expectedCategory: 'Announcements',
|
||||
},
|
||||
];
|
||||
|
||||
for (const testCase of cases) {
|
||||
const config: Config = {
|
||||
github_token: 'not-a-real-token',
|
||||
github_ref: 'refs/heads/main',
|
||||
github_repository: 'owner/remote',
|
||||
input_tag_name: testCase.name,
|
||||
input_name: `Release ${testCase.name}`,
|
||||
input_files: [],
|
||||
input_draft: false,
|
||||
input_prerelease: true,
|
||||
input_fail_on_unmatched_files: false,
|
||||
input_generate_release_notes: false,
|
||||
input_append_body: false,
|
||||
input_make_latest: undefined,
|
||||
};
|
||||
if (testCase.categoryProperty === 'present') {
|
||||
config.input_discussion_category_name = testCase.category;
|
||||
}
|
||||
|
||||
const releaser = new GitHubReleaser(
|
||||
getOctokit(config.github_token, {
|
||||
baseUrl,
|
||||
}),
|
||||
);
|
||||
await expect(release(config, releaser, 1)).resolves.toMatchObject({
|
||||
release: { tag_name: testCase.name },
|
||||
created: true,
|
||||
});
|
||||
|
||||
const request = requests.at(-1);
|
||||
expect(request).toMatchObject({
|
||||
path: '/repos/owner/remote/releases',
|
||||
authorization: 'token not-a-real-token',
|
||||
contentType: 'application/json; charset=utf-8',
|
||||
body: {
|
||||
tag_name: testCase.name,
|
||||
name: `Release ${testCase.name}`,
|
||||
draft: false,
|
||||
prerelease: true,
|
||||
generate_release_notes: false,
|
||||
},
|
||||
});
|
||||
if (testCase.expectedCategory) {
|
||||
expect(request?.body.discussion_category_name).toBe(testCase.expectedCategory);
|
||||
} else {
|
||||
expect(request?.body).not.toHaveProperty('discussion_category_name');
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,385 @@
|
||||
import type { Config } from '../src/util';
|
||||
import type { Release } from '../src/github';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
setFailed: vi.fn(),
|
||||
setOutput: vi.fn(),
|
||||
getOctokit: vi.fn(),
|
||||
GitHubReleaser: vi.fn(function () {
|
||||
return mocks.releaser;
|
||||
}),
|
||||
releaser: { name: 'releaser' },
|
||||
release: vi.fn(),
|
||||
finalizeRelease: vi.fn(),
|
||||
upload: vi.fn(),
|
||||
listReleaseAssets: vi.fn(),
|
||||
parseConfig: vi.fn(),
|
||||
isTag: vi.fn(),
|
||||
paths: vi.fn(),
|
||||
unmatchedPatterns: vi.fn(),
|
||||
uploadUrl: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@actions/core', () => ({
|
||||
setFailed: mocks.setFailed,
|
||||
setOutput: mocks.setOutput,
|
||||
}));
|
||||
|
||||
vi.mock('@actions/github', () => ({ getOctokit: mocks.getOctokit }));
|
||||
|
||||
vi.mock('../src/github', () => ({
|
||||
GitHubReleaser: mocks.GitHubReleaser,
|
||||
release: mocks.release,
|
||||
finalizeRelease: mocks.finalizeRelease,
|
||||
upload: mocks.upload,
|
||||
listReleaseAssets: mocks.listReleaseAssets,
|
||||
}));
|
||||
|
||||
vi.mock('../src/util', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../src/util')>();
|
||||
return {
|
||||
...actual,
|
||||
parseConfig: mocks.parseConfig,
|
||||
isTag: mocks.isTag,
|
||||
paths: mocks.paths,
|
||||
unmatchedPatterns: mocks.unmatchedPatterns,
|
||||
uploadUrl: mocks.uploadUrl,
|
||||
};
|
||||
});
|
||||
|
||||
import { run } from '../src/run';
|
||||
|
||||
const baseConfig: Config = {
|
||||
github_token: 'token',
|
||||
github_ref: 'refs/tags/v1.0.0',
|
||||
github_repository: 'owner/repo',
|
||||
input_files: [],
|
||||
input_draft: false,
|
||||
input_prerelease: false,
|
||||
input_preserve_order: false,
|
||||
input_overwrite_files: true,
|
||||
input_fail_on_unmatched_files: false,
|
||||
input_generate_release_notes: false,
|
||||
input_append_body: false,
|
||||
input_make_latest: undefined,
|
||||
};
|
||||
|
||||
const initialRelease: Release = {
|
||||
id: 41,
|
||||
upload_url: 'https://uploads.example.test/releases/41/assets{?name,label}',
|
||||
html_url: 'https://example.test/releases/41',
|
||||
tag_name: 'v1.0.0',
|
||||
name: 'v1.0.0',
|
||||
target_commitish: 'main',
|
||||
draft: true,
|
||||
prerelease: false,
|
||||
assets: [{ id: 1, name: 'existing.zip' }],
|
||||
};
|
||||
|
||||
const finalizedRelease: Release = {
|
||||
...initialRelease,
|
||||
id: 42,
|
||||
upload_url: 'https://uploads.example.test/releases/42/assets{?name,label}',
|
||||
html_url: 'https://example.test/releases/42',
|
||||
draft: false,
|
||||
};
|
||||
|
||||
const deferred = <T>() => {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((resolvePromise) => {
|
||||
resolve = resolvePromise;
|
||||
});
|
||||
return { promise, resolve };
|
||||
};
|
||||
|
||||
describe('run', () => {
|
||||
let config: Config;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
config = { ...baseConfig };
|
||||
mocks.parseConfig.mockImplementation(() => config);
|
||||
mocks.isTag.mockImplementation((ref: string) => ref.startsWith('refs/tags/'));
|
||||
mocks.unmatchedPatterns.mockReturnValue([]);
|
||||
mocks.paths.mockReturnValue([]);
|
||||
mocks.uploadUrl.mockImplementation((url: string) => url.split('{')[0]);
|
||||
mocks.getOctokit.mockReturnValue({ name: 'octokit' });
|
||||
mocks.release.mockResolvedValue({ release: initialRelease, created: true });
|
||||
mocks.finalizeRelease.mockResolvedValue(finalizedRelease);
|
||||
mocks.upload.mockResolvedValue(undefined);
|
||||
mocks.listReleaseAssets.mockResolvedValue([]);
|
||||
vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['an explicit tag', { github_ref: 'refs/heads/main', input_tag_name: 'v1.0.0' }],
|
||||
['a tag ref', { github_ref: 'refs/tags/v1.0.0', input_tag_name: undefined }],
|
||||
[
|
||||
'a draft without a tag',
|
||||
{ github_ref: 'refs/heads/main', input_tag_name: undefined, input_draft: true },
|
||||
],
|
||||
])('accepts %s', async (_name, patch) => {
|
||||
config = { ...config, ...patch };
|
||||
|
||||
await run();
|
||||
|
||||
expect(mocks.release).toHaveBeenCalledOnce();
|
||||
expect(mocks.setFailed).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports the documented tag requirement for a non-tag ref', async () => {
|
||||
config = { ...config, github_ref: 'refs/heads/main', input_tag_name: undefined };
|
||||
|
||||
await run();
|
||||
|
||||
expect(mocks.setFailed).toHaveBeenCalledWith('⚠️ GitHub Releases requires a tag');
|
||||
expect(mocks.release).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('warns with each unmatched input pattern when strict matching is disabled', async () => {
|
||||
config = { ...config, input_files: ['dist/*.zip'] };
|
||||
mocks.unmatchedPatterns.mockReturnValue(['dist/*.zip']);
|
||||
|
||||
await run();
|
||||
|
||||
expect(console.warn).toHaveBeenCalledWith("🤔 Pattern 'dist/*.zip' does not match any files.");
|
||||
expect(mocks.setFailed).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fails with the unmatched input pattern when strict matching is enabled', async () => {
|
||||
config = {
|
||||
...config,
|
||||
input_files: ['dist/*.zip'],
|
||||
input_fail_on_unmatched_files: true,
|
||||
};
|
||||
mocks.unmatchedPatterns.mockReturnValue(['dist/*.zip']);
|
||||
|
||||
await run();
|
||||
|
||||
expect(mocks.setFailed).toHaveBeenCalledWith(
|
||||
"⚠️ Pattern 'dist/*.zip' does not match any files.",
|
||||
);
|
||||
expect(mocks.release).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[false, 'warns', '🤔 dist/*.zip does not include a valid file.'],
|
||||
[true, 'fails', '⚠️ dist/*.zip does not include a valid file.'],
|
||||
])(
|
||||
'%s strict matching %s when resolved patterns contain no files',
|
||||
async (strict, _verb, message) => {
|
||||
config = {
|
||||
...config,
|
||||
input_files: ['dist/*.zip'],
|
||||
input_fail_on_unmatched_files: strict,
|
||||
};
|
||||
|
||||
await run();
|
||||
|
||||
if (strict) {
|
||||
expect(mocks.setFailed).toHaveBeenCalledWith(message);
|
||||
expect(mocks.finalizeRelease).not.toHaveBeenCalled();
|
||||
} else {
|
||||
expect(console.warn).toHaveBeenCalledWith(message);
|
||||
expect(mocks.finalizeRelease).toHaveBeenCalledOnce();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it('forwards valid files and the working directory through upload boundaries', async () => {
|
||||
config = {
|
||||
...config,
|
||||
input_files: ['dist/*.zip'],
|
||||
input_working_directory: 'fixture',
|
||||
};
|
||||
mocks.paths.mockReturnValue(['fixture/dist/action.zip']);
|
||||
mocks.upload.mockResolvedValue({ id: 7 });
|
||||
mocks.listReleaseAssets.mockResolvedValue([{ id: 7, name: 'action.zip' }]);
|
||||
|
||||
await run();
|
||||
|
||||
expect(mocks.paths).toHaveBeenCalledWith(['dist/*.zip'], 'fixture');
|
||||
expect(mocks.upload).toHaveBeenCalledWith(
|
||||
config,
|
||||
mocks.releaser,
|
||||
'https://uploads.example.test/releases/41/assets',
|
||||
'fixture/dist/action.zip',
|
||||
initialRelease.assets,
|
||||
initialRelease.id,
|
||||
);
|
||||
});
|
||||
|
||||
it('starts uploads concurrently by default', async () => {
|
||||
config = { ...config, input_files: ['one.zip', 'two.zip'] };
|
||||
mocks.paths.mockReturnValue(['one.zip', 'two.zip']);
|
||||
const first = deferred<{ id: number }>();
|
||||
const second = deferred<{ id: number }>();
|
||||
mocks.upload.mockImplementation((_config, _releaser, _url, path) =>
|
||||
path === 'one.zip' ? first.promise : second.promise,
|
||||
);
|
||||
|
||||
const result = run();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(mocks.upload).toHaveBeenCalledTimes(2);
|
||||
first.resolve({ id: 1 });
|
||||
second.resolve({ id: 2 });
|
||||
await result;
|
||||
});
|
||||
|
||||
it('waits for each upload when preserve_order is enabled', async () => {
|
||||
config = {
|
||||
...config,
|
||||
input_files: ['one.zip', 'two.zip'],
|
||||
input_preserve_order: true,
|
||||
};
|
||||
mocks.paths.mockReturnValue(['one.zip', 'two.zip']);
|
||||
const first = deferred<{ id: number }>();
|
||||
const second = deferred<{ id: number }>();
|
||||
mocks.upload.mockImplementation((_config, _releaser, _url, path) =>
|
||||
path === 'one.zip' ? first.promise : second.promise,
|
||||
);
|
||||
|
||||
const result = run();
|
||||
await Promise.resolve();
|
||||
expect(mocks.upload).toHaveBeenCalledTimes(1);
|
||||
|
||||
first.resolve({ id: 1 });
|
||||
await vi.waitFor(() => expect(mocks.upload).toHaveBeenCalledTimes(2));
|
||||
|
||||
second.resolve({ id: 2 });
|
||||
await result;
|
||||
});
|
||||
|
||||
it('keeps an existing draft unpublished until its upload completes', async () => {
|
||||
config = {
|
||||
...config,
|
||||
input_draft: false,
|
||||
input_prerelease: true,
|
||||
input_files: ['asset.zip'],
|
||||
};
|
||||
mocks.release.mockResolvedValue({ release: initialRelease, created: false });
|
||||
mocks.paths.mockReturnValue(['asset.zip']);
|
||||
const pendingUpload = deferred<{ id: number }>();
|
||||
mocks.upload.mockReturnValue(pendingUpload.promise);
|
||||
|
||||
const result = run();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(mocks.upload).toHaveBeenCalledOnce();
|
||||
expect(mocks.finalizeRelease).not.toHaveBeenCalled();
|
||||
|
||||
pendingUpload.resolve({ id: 7 });
|
||||
await result;
|
||||
|
||||
expect(mocks.finalizeRelease).toHaveBeenCalledWith(
|
||||
config,
|
||||
mocks.releaser,
|
||||
initialRelease,
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves an existing draft recoverable when an upload fails', async () => {
|
||||
config = {
|
||||
...config,
|
||||
input_draft: false,
|
||||
input_prerelease: true,
|
||||
input_files: ['asset.zip'],
|
||||
};
|
||||
mocks.release.mockResolvedValue({ release: initialRelease, created: false });
|
||||
mocks.paths.mockReturnValue(['asset.zip']);
|
||||
mocks.upload.mockRejectedValue(new Error('upload failed'));
|
||||
|
||||
await run();
|
||||
|
||||
expect(mocks.finalizeRelease).not.toHaveBeenCalled();
|
||||
expect(mocks.setFailed).toHaveBeenCalledWith('upload failed');
|
||||
});
|
||||
|
||||
it('finalizes after uploads and outputs only newly uploaded assets without uploader data', async () => {
|
||||
config = { ...config, input_files: ['one.zip', 'skipped.zip', 'two.zip'] };
|
||||
mocks.paths.mockReturnValue(['one.zip', 'skipped.zip', 'two.zip']);
|
||||
mocks.upload
|
||||
.mockResolvedValueOnce({ id: 7 })
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValueOnce({ id: 9 });
|
||||
mocks.listReleaseAssets.mockResolvedValue([
|
||||
{ id: 1, name: 'existing.zip', uploader: { login: 'someone' } },
|
||||
{ id: 7, name: 'one.zip', size: 10, uploader: { login: 'bot' } },
|
||||
{ id: 9, name: 'two.zip', label: 'Two', uploader: null },
|
||||
]);
|
||||
|
||||
await run();
|
||||
|
||||
expect(mocks.release.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
mocks.upload.mock.invocationCallOrder[0],
|
||||
);
|
||||
expect(mocks.upload.mock.invocationCallOrder[2]).toBeLessThan(
|
||||
mocks.finalizeRelease.mock.invocationCallOrder[0],
|
||||
);
|
||||
expect(mocks.finalizeRelease).toHaveBeenCalledWith(
|
||||
config,
|
||||
mocks.releaser,
|
||||
initialRelease,
|
||||
true,
|
||||
);
|
||||
expect(mocks.listReleaseAssets).toHaveBeenCalledWith(config, mocks.releaser, finalizedRelease);
|
||||
expect(mocks.setOutput).toHaveBeenCalledWith('assets', [
|
||||
{ id: 7, name: 'one.zip', size: 10 },
|
||||
{ id: 9, name: 'two.zip', label: 'Two' },
|
||||
]);
|
||||
expect(mocks.setOutput).toHaveBeenCalledWith('url', finalizedRelease.html_url);
|
||||
expect(mocks.setOutput).toHaveBeenCalledWith('id', '42');
|
||||
expect(mocks.setOutput).toHaveBeenCalledWith('upload_url', finalizedRelease.upload_url);
|
||||
});
|
||||
|
||||
it('outputs an empty asset list and skips refresh when no upload returns an ID', async () => {
|
||||
config = { ...config, input_files: ['skipped.zip'] };
|
||||
mocks.paths.mockReturnValue(['skipped.zip']);
|
||||
mocks.upload.mockResolvedValue(undefined);
|
||||
|
||||
await run();
|
||||
|
||||
expect(mocks.listReleaseAssets).not.toHaveBeenCalled();
|
||||
expect(mocks.setOutput).toHaveBeenCalledWith('assets', []);
|
||||
});
|
||||
|
||||
it('configures rate-limit and abuse callbacks without making live requests', async () => {
|
||||
await run();
|
||||
|
||||
const options = mocks.getOctokit.mock.calls[0][1];
|
||||
const request = { method: 'GET', url: '/repos/owner/repo', request: { retryCount: 0 } };
|
||||
expect(options.throttle.onRateLimit(5, request)).toBe(true);
|
||||
expect(
|
||||
options.throttle.onRateLimit(5, { ...request, request: { retryCount: 1 } }),
|
||||
).toBeUndefined();
|
||||
expect(options.throttle.onAbuseLimit(10, request)).toBeUndefined();
|
||||
expect(console.warn).toHaveBeenCalledWith(
|
||||
'Request quota exhausted for request GET /repos/owner/repo',
|
||||
);
|
||||
expect(console.warn).toHaveBeenCalledWith('Abuse detected for request GET /repos/owner/repo');
|
||||
});
|
||||
|
||||
it.each([
|
||||
[new Error('release failed'), 'release failed'],
|
||||
['release failed', 'release failed'],
|
||||
[{ message: 'release failed' }, 'release failed'],
|
||||
[null, 'Unknown error'],
|
||||
[undefined, 'Unknown error'],
|
||||
])('normalizes a thrown value before reporting failure', async (thrown, expected) => {
|
||||
mocks.parseConfig.mockImplementation(() => {
|
||||
throw thrown;
|
||||
});
|
||||
|
||||
await run();
|
||||
|
||||
expect(mocks.setFailed).toHaveBeenCalledWith(expected);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,394 @@
|
||||
import { getOctokit } from '@actions/github';
|
||||
import { createHash } from 'crypto';
|
||||
import { createServer, type Server } from 'http';
|
||||
import { type AddressInfo } from 'net';
|
||||
import { mkdtemp, open, rm, writeFile } from 'fs/promises';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { GitHubReleaser, upload } from '../src/github';
|
||||
import type { Config } from '../src/util';
|
||||
|
||||
const openFile = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('fs/promises', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('fs/promises')>();
|
||||
openFile.mockImplementation(actual.open);
|
||||
return { ...actual, open: openFile };
|
||||
});
|
||||
|
||||
type Fixture = {
|
||||
name: string;
|
||||
path: string;
|
||||
size: number;
|
||||
digest: string;
|
||||
contentType: string;
|
||||
bytes?: Buffer;
|
||||
};
|
||||
|
||||
type Receipt = {
|
||||
method: string | undefined;
|
||||
pathname: string;
|
||||
filename: string | null;
|
||||
contentLength: string | undefined;
|
||||
contentType: string | undefined;
|
||||
size: number;
|
||||
digest: string;
|
||||
chunks: number;
|
||||
chunkTypes: string[];
|
||||
bytes?: Buffer;
|
||||
};
|
||||
|
||||
const config: Config = {
|
||||
github_token: 'not-a-real-token',
|
||||
github_ref: 'refs/tags/v1.0.0',
|
||||
github_repository: 'owner/repo',
|
||||
input_files: [],
|
||||
input_fail_on_unmatched_files: false,
|
||||
input_generate_release_notes: false,
|
||||
input_append_body: false,
|
||||
input_make_latest: undefined,
|
||||
};
|
||||
|
||||
const sha256 = (data: Buffer): string => createHash('sha256').update(data).digest('hex');
|
||||
|
||||
const expectLastFileHandleClosed = async (): Promise<void> => {
|
||||
const result = openFile.mock.results.at(-1);
|
||||
expect(result?.type).toBe('return');
|
||||
const fileHandle = await result?.value;
|
||||
await expect(fileHandle.stat()).rejects.toMatchObject({ code: 'EBADF' });
|
||||
};
|
||||
|
||||
const closeServer = async (server: Server): Promise<void> => {
|
||||
server.closeIdleConnections();
|
||||
server.closeAllConnections();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
};
|
||||
|
||||
const startUploadServer = async (
|
||||
responseStatus: (requestIndex: number) => number = () => 201,
|
||||
): Promise<{ server: Server; uploadUrl: string; receipts: Receipt[] }> => {
|
||||
const receipts: Receipt[] = [];
|
||||
const server = createServer(async (request, response) => {
|
||||
const url = new URL(request.url || '/', 'http://127.0.0.1');
|
||||
const hash = createHash('sha256');
|
||||
const bufferedChunks: Buffer[] = [];
|
||||
const chunkTypes: string[] = [];
|
||||
let size = 0;
|
||||
let chunks = 0;
|
||||
|
||||
for await (const chunk of request) {
|
||||
chunkTypes.push(chunk?.constructor?.name || typeof chunk);
|
||||
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||
chunks += 1;
|
||||
size += bytes.length;
|
||||
hash.update(bytes);
|
||||
if (size <= 1024 * 1024) {
|
||||
bufferedChunks.push(Buffer.from(bytes));
|
||||
}
|
||||
}
|
||||
|
||||
receipts.push({
|
||||
method: request.method,
|
||||
pathname: url.pathname,
|
||||
filename: url.searchParams.get('name'),
|
||||
contentLength: request.headers['content-length'],
|
||||
contentType: request.headers['content-type'],
|
||||
size,
|
||||
digest: hash.digest('hex'),
|
||||
chunks,
|
||||
chunkTypes,
|
||||
bytes: size <= 1024 * 1024 ? Buffer.concat(bufferedChunks) : undefined,
|
||||
});
|
||||
|
||||
const status = responseStatus(receipts.length - 1);
|
||||
response.writeHead(status, { 'content-type': 'application/json' });
|
||||
response.end(
|
||||
JSON.stringify(
|
||||
status === 201
|
||||
? { id: 123, name: url.searchParams.get('name') }
|
||||
: {
|
||||
message: 'Validation Failed',
|
||||
errors: [{ code: 'already_exists' }],
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once('error', reject);
|
||||
server.listen(0, '127.0.0.1', resolve);
|
||||
});
|
||||
const address = server.address() as AddressInfo;
|
||||
return {
|
||||
server,
|
||||
uploadUrl: `http://127.0.0.1:${address.port}/repos/owner/repo/releases/1/assets`,
|
||||
receipts,
|
||||
};
|
||||
};
|
||||
|
||||
describe('release asset upload transport', () => {
|
||||
let tempDirectory: string;
|
||||
let fixtures: Fixture[];
|
||||
|
||||
beforeAll(async () => {
|
||||
tempDirectory = await mkdtemp(join(tmpdir(), 'action-gh-release-upload-'));
|
||||
|
||||
const smallFixtures = [
|
||||
{
|
||||
name: 'artifact.zip.sha512',
|
||||
bytes: Buffer.from(`${'a'.repeat(128)}\n`),
|
||||
contentType: 'application/octet-stream',
|
||||
},
|
||||
{
|
||||
name: 'artifact.md5',
|
||||
bytes: Buffer.from(`${'b'.repeat(32)}\n`),
|
||||
contentType: 'application/octet-stream',
|
||||
},
|
||||
{ name: 'one-byte.txt', bytes: Buffer.from([0x7f]), contentType: 'text/plain' },
|
||||
{ name: 'empty.bin', bytes: Buffer.alloc(0), contentType: 'application/octet-stream' },
|
||||
{
|
||||
name: 'binary.bin',
|
||||
bytes: Buffer.from([0x00, 0x01, 0x7f, 0x80, 0xfe, 0xff]),
|
||||
contentType: 'application/octet-stream',
|
||||
},
|
||||
];
|
||||
|
||||
fixtures = [];
|
||||
for (const fixture of smallFixtures) {
|
||||
const path = join(tempDirectory, fixture.name);
|
||||
await writeFile(path, fixture.bytes);
|
||||
fixtures.push({
|
||||
...fixture,
|
||||
path,
|
||||
size: fixture.bytes.length,
|
||||
digest: sha256(fixture.bytes),
|
||||
});
|
||||
}
|
||||
|
||||
const largePath = join(tempDirectory, 'large.bin');
|
||||
const largeHandle = await open(largePath, 'w');
|
||||
const largeHash = createHash('sha256');
|
||||
const block = Buffer.alloc(64 * 1024);
|
||||
for (let index = 0; index < block.length; index += 1) {
|
||||
block[index] = index % 251;
|
||||
}
|
||||
try {
|
||||
for (let index = 0; index < 128; index += 1) {
|
||||
await largeHandle.write(block);
|
||||
largeHash.update(block);
|
||||
}
|
||||
} finally {
|
||||
await largeHandle.close();
|
||||
}
|
||||
fixtures.push({
|
||||
name: 'large.bin',
|
||||
path: largePath,
|
||||
size: 8 * 1024 * 1024,
|
||||
digest: largeHash.digest('hex'),
|
||||
contentType: 'application/octet-stream',
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await rm(tempDirectory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it.each([0, 1, 2, 3, 4, 5])(
|
||||
'uploads fixture %i through the real Octokit request path',
|
||||
async (fixtureIndex) => {
|
||||
const fixture = fixtures[fixtureIndex];
|
||||
const { server, uploadUrl, receipts } = await startUploadServer();
|
||||
const releaser = new GitHubReleaser(getOctokit(config.github_token));
|
||||
openFile.mockClear();
|
||||
|
||||
try {
|
||||
await expect(upload(config, releaser, uploadUrl, fixture.path, [], 1)).resolves.toEqual({
|
||||
id: 123,
|
||||
name: fixture.name,
|
||||
});
|
||||
|
||||
expect(receipts).toHaveLength(1);
|
||||
expect(receipts[0]).toMatchObject({
|
||||
method: 'POST',
|
||||
pathname: '/repos/owner/repo/releases/1/assets',
|
||||
filename: fixture.name,
|
||||
contentLength: String(fixture.size),
|
||||
contentType: fixture.contentType,
|
||||
size: fixture.size,
|
||||
digest: fixture.digest,
|
||||
});
|
||||
if (fixture.bytes) {
|
||||
expect(receipts[0].bytes).toEqual(fixture.bytes);
|
||||
}
|
||||
expect(new Set(receipts[0].chunkTypes)).toEqual(
|
||||
fixture.size === 0 ? new Set() : new Set(['Buffer']),
|
||||
);
|
||||
if (fixture.size > 1024 * 1024) {
|
||||
expect(receipts[0].chunks).toBeGreaterThan(1);
|
||||
}
|
||||
await expectLastFileHandleClosed();
|
||||
} finally {
|
||||
await closeServer(server);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it('closes the file handle when the request fails', async () => {
|
||||
const fixture = fixtures[0];
|
||||
const { server, uploadUrl } = await startUploadServer(() => 500);
|
||||
const releaser = new GitHubReleaser(getOctokit(config.github_token));
|
||||
openFile.mockClear();
|
||||
|
||||
try {
|
||||
await expect(upload(config, releaser, uploadUrl, fixture.path, [], 1)).rejects.toThrow(
|
||||
'Validation Failed',
|
||||
);
|
||||
await expectLastFileHandleClosed();
|
||||
} finally {
|
||||
await closeServer(server);
|
||||
}
|
||||
});
|
||||
|
||||
it('normalizes ArrayBuffer chunks before they reach the Octokit transport', async () => {
|
||||
const fixture = fixtures[0];
|
||||
const { server, uploadUrl, receipts } = await startUploadServer();
|
||||
const releaser = new GitHubReleaser(getOctokit(config.github_token));
|
||||
const actual = await vi.importActual<typeof import('fs/promises')>('fs/promises');
|
||||
openFile.mockImplementationOnce(async (...args: Parameters<typeof actual.open>) => {
|
||||
const fileHandle = await actual.open(...args);
|
||||
const readableWebStream = fileHandle.readableWebStream.bind(fileHandle);
|
||||
Object.defineProperty(fileHandle, 'readableWebStream', {
|
||||
configurable: true,
|
||||
value: () =>
|
||||
readableWebStream().pipeThrough(
|
||||
new TransformStream<Uint8Array, ArrayBuffer>({
|
||||
transform(chunk, controller) {
|
||||
controller.enqueue(chunk.slice().buffer);
|
||||
},
|
||||
}),
|
||||
),
|
||||
});
|
||||
return fileHandle;
|
||||
});
|
||||
|
||||
try {
|
||||
await expect(upload(config, releaser, uploadUrl, fixture.path, [], 1)).resolves.toEqual({
|
||||
id: 123,
|
||||
name: fixture.name,
|
||||
});
|
||||
expect(receipts).toHaveLength(1);
|
||||
expect(receipts[0]).toMatchObject({
|
||||
size: fixture.size,
|
||||
digest: fixture.digest,
|
||||
});
|
||||
await expectLastFileHandleClosed();
|
||||
} finally {
|
||||
await closeServer(server);
|
||||
}
|
||||
});
|
||||
|
||||
it('opens a fresh upload body after an already-exists response', async () => {
|
||||
const fixture = fixtures[0];
|
||||
const { server, uploadUrl, receipts } = await startUploadServer((index) =>
|
||||
index === 0 ? 422 : 201,
|
||||
);
|
||||
const releaser = new GitHubReleaser(getOctokit(config.github_token));
|
||||
vi.spyOn(releaser, 'listReleaseAssets').mockResolvedValue([{ id: 9, name: fixture.name }]);
|
||||
const deleteReleaseAsset = vi
|
||||
.spyOn(releaser, 'deleteReleaseAsset')
|
||||
.mockResolvedValue(undefined);
|
||||
openFile.mockClear();
|
||||
|
||||
try {
|
||||
await expect(upload(config, releaser, uploadUrl, fixture.path, [], 1)).resolves.toEqual({
|
||||
id: 123,
|
||||
name: fixture.name,
|
||||
});
|
||||
expect(receipts).toHaveLength(2);
|
||||
expect(receipts.map(({ size }) => size)).toEqual([fixture.size, fixture.size]);
|
||||
expect(receipts.map(({ digest }) => digest)).toEqual([fixture.digest, fixture.digest]);
|
||||
expect(openFile).toHaveBeenCalledTimes(2);
|
||||
expect(deleteReleaseAsset).toHaveBeenCalledWith({
|
||||
owner: 'owner',
|
||||
repo: 'repo',
|
||||
release_id: 1,
|
||||
asset_id: 9,
|
||||
});
|
||||
for (const result of openFile.mock.results) {
|
||||
const fileHandle = await result.value;
|
||||
await expect(fileHandle.stat()).rejects.toMatchObject({ code: 'EBADF' });
|
||||
}
|
||||
} finally {
|
||||
await closeServer(server);
|
||||
}
|
||||
});
|
||||
|
||||
it('falls back to the release-scoped delete route through the real Octokit request path', async () => {
|
||||
const requests: Array<{
|
||||
method: string | undefined;
|
||||
pathname: string;
|
||||
authorization: string | undefined;
|
||||
}> = [];
|
||||
const server = createServer((request, response) => {
|
||||
const url = new URL(request.url || '/', 'http://127.0.0.1');
|
||||
requests.push({
|
||||
method: request.method,
|
||||
pathname: url.pathname,
|
||||
authorization: request.headers.authorization,
|
||||
});
|
||||
|
||||
if (url.pathname === '/repos/owner/repo/releases/assets/9') {
|
||||
response.writeHead(404, { 'content-type': 'application/json' });
|
||||
response.end(JSON.stringify({ message: 'page not found' }));
|
||||
return;
|
||||
}
|
||||
if (url.pathname === '/repos/owner/repo/releases/1/assets/9') {
|
||||
response.writeHead(204);
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
|
||||
response.writeHead(500, { 'content-type': 'application/json' });
|
||||
response.end(JSON.stringify({ message: 'unexpected route' }));
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once('error', reject);
|
||||
server.listen(0, '127.0.0.1', resolve);
|
||||
});
|
||||
const address = server.address() as AddressInfo;
|
||||
const releaser = new GitHubReleaser(
|
||||
getOctokit(config.github_token, {
|
||||
baseUrl: `http://127.0.0.1:${address.port}`,
|
||||
}),
|
||||
);
|
||||
|
||||
try {
|
||||
await expect(
|
||||
releaser.deleteReleaseAsset({
|
||||
owner: 'owner',
|
||||
repo: 'repo',
|
||||
release_id: 1,
|
||||
asset_id: 9,
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
expect(requests).toEqual([
|
||||
{
|
||||
method: 'DELETE',
|
||||
pathname: '/repos/owner/repo/releases/assets/9',
|
||||
authorization: 'token not-a-real-token',
|
||||
},
|
||||
{
|
||||
method: 'DELETE',
|
||||
pathname: '/repos/owner/repo/releases/1/assets/9',
|
||||
authorization: 'token not-a-real-token',
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
await closeServer(server);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
alignAssetName,
|
||||
errorMessage,
|
||||
expandHomePattern,
|
||||
isTag,
|
||||
normalizeFilePattern,
|
||||
@@ -25,6 +26,26 @@ describe('util', () => {
|
||||
'https://uploads.github.com/repos/octocat/Hello-World/releases/1/assets',
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves a URL without a template unchanged', () => {
|
||||
assert.equal(
|
||||
uploadUrl('https://uploads.github.com/repos/octocat/Hello-World/releases/1/assets'),
|
||||
'https://uploads.github.com/repos/octocat/Hello-World/releases/1/assets',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('errorMessage', () => {
|
||||
it.each([
|
||||
[new Error('failed'), 'failed'],
|
||||
[{ message: 'failed' }, 'failed'],
|
||||
['failed', 'failed'],
|
||||
[42, '42'],
|
||||
[null, 'Unknown error'],
|
||||
[undefined, 'Unknown error'],
|
||||
])('normalizes unknown thrown values', (error, expected) => {
|
||||
expect(errorMessage(error)).toBe(expected);
|
||||
});
|
||||
});
|
||||
describe('parseInputFiles', () => {
|
||||
it('parses empty strings', () => {
|
||||
@@ -213,6 +234,10 @@ describe('util', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('treats an empty draft input as omitted', () => {
|
||||
assert.strictEqual(parseConfig({ INPUT_DRAFT: '' }).input_draft, undefined);
|
||||
});
|
||||
|
||||
it('parses basic config with commitish', () => {
|
||||
assert.deepStrictEqual(
|
||||
parseConfig({
|
||||
|
||||
Vendored
+31
-31
File diff suppressed because one or more lines are too long
Generated
+542
-421
File diff suppressed because it is too large
Load Diff
+3
-6
@@ -33,15 +33,12 @@
|
||||
"mime-types": "^3.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/glob": "^9.0.0",
|
||||
"@types/mime-types": "^3.0.1",
|
||||
"@types/node": "^24",
|
||||
"@vitest/coverage-v8": "^4.1.8",
|
||||
"@vitest/coverage-v8": "^4.1.9",
|
||||
"esbuild": "^0.28.1",
|
||||
"prettier": "3.8.3",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-formatter": "^7.2.2",
|
||||
"prettier": "3.9.0",
|
||||
"typescript": "^7.0.2",
|
||||
"vitest": "^4.0.4"
|
||||
}
|
||||
}
|
||||
|
||||
+192
-63
@@ -1,12 +1,26 @@
|
||||
import { GitHub } from '@actions/github/lib/utils';
|
||||
import { statSync } from 'fs';
|
||||
import { open } from 'fs/promises';
|
||||
import { open, type FileHandle } from 'fs/promises';
|
||||
import { lookup } from 'mime-types';
|
||||
import { basename } from 'path';
|
||||
import { alignAssetName, Config, isTag, normalizeTagName, releaseBody } from './util';
|
||||
import { alignAssetName, Config, errorMessage, isTag, normalizeTagName, releaseBody } from './util';
|
||||
|
||||
type GitHub = InstanceType<typeof GitHub>;
|
||||
|
||||
type UploadChunk = ArrayBuffer | Uint8Array<ArrayBufferLike>;
|
||||
type UploadBody = ReadableStream<Uint8Array<ArrayBufferLike>>;
|
||||
|
||||
const fileUploadStream = (fileHandle: FileHandle): UploadBody => {
|
||||
const source = fileHandle.readableWebStream() as ReadableStream<UploadChunk>;
|
||||
return source.pipeThrough(
|
||||
new TransformStream<UploadChunk, Uint8Array<ArrayBufferLike>>({
|
||||
transform(chunk, controller) {
|
||||
controller.enqueue(chunk instanceof Uint8Array ? chunk : new Uint8Array(chunk));
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
export interface ReleaseAsset {
|
||||
name: string;
|
||||
mime: string;
|
||||
@@ -54,6 +68,43 @@ type ReleaseMutationParams = {
|
||||
previous_tag_name?: string;
|
||||
};
|
||||
|
||||
class ReleaseCreationError extends Error {
|
||||
readonly status = 404;
|
||||
|
||||
constructor(message: string, cause: unknown) {
|
||||
super(message, { cause });
|
||||
this.name = 'ReleaseCreationError';
|
||||
}
|
||||
}
|
||||
|
||||
class ReleaseAccessError extends Error {
|
||||
readonly status = 404;
|
||||
|
||||
constructor(message: string, cause: unknown) {
|
||||
super(message, { cause });
|
||||
this.name = 'ReleaseAccessError';
|
||||
}
|
||||
}
|
||||
|
||||
const repositoryAccessGuidance = (owner: string, repo: string): string =>
|
||||
`Verify that ${owner}/${repo} exists under the expected owner, the token can access it, the repository is selected when using a fine-grained PAT, and the token has Contents: write permission.`;
|
||||
|
||||
const releaseCreation404Message = (
|
||||
owner: string,
|
||||
repo: string,
|
||||
discussionCategory: string | undefined,
|
||||
error: unknown,
|
||||
): string => {
|
||||
const discussionGuidance = discussionCategory
|
||||
? ` Also verify that Discussions and the requested category "${discussionCategory}" are enabled.`
|
||||
: '';
|
||||
|
||||
return `GitHub returned 404 while creating the release. ${repositoryAccessGuidance(owner, repo)}${discussionGuidance} GitHub response: ${errorMessage(error)}`;
|
||||
};
|
||||
|
||||
const releaseLookup404Message = (owner: string, repo: string, error: unknown): string =>
|
||||
`GitHub returned 404 while checking existing releases. ${repositoryAccessGuidance(owner, repo)} GitHub response: ${errorMessage(error)}`;
|
||||
|
||||
export interface Releaser {
|
||||
getReleaseByTag(params: { owner: string; repo: string; tag: string }): Promise<{ data: Release }>;
|
||||
|
||||
@@ -82,7 +133,12 @@ export interface Releaser {
|
||||
release_id: number;
|
||||
}): 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;
|
||||
release_id: number;
|
||||
asset_id: number;
|
||||
}): Promise<void>;
|
||||
|
||||
deleteRelease(params: { owner: string; repo: string; release_id: number }): Promise<void>;
|
||||
|
||||
@@ -99,7 +155,7 @@ export interface Releaser {
|
||||
size: number;
|
||||
mime: string;
|
||||
token: string;
|
||||
data: any;
|
||||
data: UploadBody;
|
||||
}): Promise<{ status: number; data: any }>;
|
||||
}
|
||||
|
||||
@@ -215,9 +271,34 @@ export class GitHubReleaser implements Releaser {
|
||||
async deleteReleaseAsset(params: {
|
||||
owner: string;
|
||||
repo: string;
|
||||
release_id: number;
|
||||
asset_id: number;
|
||||
}): Promise<void> {
|
||||
await this.github.rest.repos.deleteReleaseAsset(params);
|
||||
const { release_id, ...githubParams } = params;
|
||||
try {
|
||||
await this.github.rest.repos.deleteReleaseAsset(githubParams);
|
||||
} catch (error: unknown) {
|
||||
const status =
|
||||
(error as { status?: number; response?: { status?: number } })?.status ??
|
||||
(error as { response?: { status?: number } })?.response?.status;
|
||||
if (status !== 404) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.github.request(
|
||||
'DELETE /repos/{owner}/{repo}/releases/{release_id}/assets/{asset_id}',
|
||||
params,
|
||||
);
|
||||
} catch (fallbackError: unknown) {
|
||||
throw new AggregateError(
|
||||
[error, fallbackError],
|
||||
`Failed to delete release asset ${params.asset_id}. GitHub endpoint: ${errorMessage(
|
||||
error,
|
||||
)}; release-scoped endpoint: ${errorMessage(fallbackError)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async deleteRelease(params: { owner: string; repo: string; release_id: number }): Promise<void> {
|
||||
@@ -239,7 +320,7 @@ export class GitHubReleaser implements Releaser {
|
||||
size: number;
|
||||
mime: string;
|
||||
token: string;
|
||||
data: any;
|
||||
data: UploadBody;
|
||||
}): Promise<{ status: number; data: any }> {
|
||||
return this.github.request({
|
||||
method: 'POST',
|
||||
@@ -307,11 +388,10 @@ export const upload = async (
|
||||
url: string,
|
||||
path: string,
|
||||
currentAssets: Array<{ id: number; name: string; label?: string | null }>,
|
||||
releaseId: number,
|
||||
): Promise<any> => {
|
||||
const [owner, repo] = config.github_repository.split('/');
|
||||
const { name, mime, size } = asset(path);
|
||||
const releaseIdMatch = url.match(/\/releases\/(\d+)\/assets/);
|
||||
const releaseId = releaseIdMatch ? Number(releaseIdMatch[1]) : undefined;
|
||||
const currentAsset = currentAssets.find(
|
||||
// GitHub can rewrite uploaded asset names, so compare against both the raw name
|
||||
// GitHub returns and the restored label we set when available.
|
||||
@@ -327,6 +407,7 @@ export const upload = async (
|
||||
asset_id: currentAsset.id || 1,
|
||||
owner,
|
||||
repo,
|
||||
release_id: releaseId,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -367,7 +448,7 @@ export const upload = async (
|
||||
size,
|
||||
mime,
|
||||
token: config.github_token,
|
||||
data: fh.readableWebStream(),
|
||||
data: fileUploadStream(fh),
|
||||
});
|
||||
} finally {
|
||||
await fh.close();
|
||||
@@ -485,6 +566,7 @@ export const upload = async (
|
||||
await releaser.deleteReleaseAsset({
|
||||
owner,
|
||||
repo,
|
||||
release_id: releaseId,
|
||||
asset_id: latestAsset.id,
|
||||
});
|
||||
return await handleUploadedAsset(await uploadAsset());
|
||||
@@ -517,23 +599,36 @@ export const release = async (
|
||||
if (generate_release_notes && previous_tag_name) {
|
||||
console.log(`📝 Generating release notes using previous tag ${previous_tag_name}`);
|
||||
}
|
||||
let _release: Release | undefined;
|
||||
try {
|
||||
const _release: Release | undefined = await findTagFromReleases(releaser, owner, repo, tag);
|
||||
|
||||
if (_release === undefined) {
|
||||
return await createRelease(
|
||||
tag,
|
||||
config,
|
||||
releaser,
|
||||
owner,
|
||||
repo,
|
||||
discussion_category_name,
|
||||
generate_release_notes,
|
||||
maxRetries,
|
||||
previous_tag_name,
|
||||
);
|
||||
_release = await findTagFromReleases(releaser, owner, repo, tag, maxRetries);
|
||||
} catch (error) {
|
||||
if (error.status === 404) {
|
||||
const diagnostic = releaseLookup404Message(owner, repo, error);
|
||||
console.log(`⚠️ ${diagnostic}`);
|
||||
throw new ReleaseAccessError(diagnostic, error);
|
||||
}
|
||||
console.log(
|
||||
`⚠️ Unexpected error fetching GitHub release for tag ${config.github_ref}: ${error}`,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (_release === undefined) {
|
||||
return await createRelease(
|
||||
tag,
|
||||
config,
|
||||
releaser,
|
||||
owner,
|
||||
repo,
|
||||
discussion_category_name,
|
||||
generate_release_notes,
|
||||
maxRetries,
|
||||
previous_tag_name,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
let existingRelease: Release = _release!;
|
||||
console.log(`Found release ${existingRelease.name} (with id=${existingRelease.id})`);
|
||||
|
||||
@@ -591,6 +686,9 @@ export const release = async (
|
||||
created: false,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof ReleaseCreationError) {
|
||||
throw error;
|
||||
}
|
||||
if (error.status !== 404) {
|
||||
console.log(
|
||||
`⚠️ Unexpected error fetching GitHub release for tag ${config.github_ref}: ${error}`,
|
||||
@@ -719,14 +817,16 @@ export const listReleaseAssets = async (
|
||||
/**
|
||||
* 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.
|
||||
* Uses the direct getReleaseByTag API for O(1) lookup. Because GitHub does not
|
||||
* expose draft releases through that endpoint, a 404 falls back to a bounded
|
||||
* scan of recent releases and briefly retries in case the listing is not yet
|
||||
* consistent.
|
||||
*
|
||||
* @param releaser - The GitHub API wrapper for release operations
|
||||
* @param owner - The owner of the repository
|
||||
* @param repo - The name of the repository
|
||||
* @param tag - The tag name to search for
|
||||
* @param listingAttempts - The maximum number of listing attempts after a direct 404
|
||||
* @returns The release with the given tag name, or undefined if no release with that tag name is found
|
||||
*/
|
||||
export async function findTagFromReleases(
|
||||
@@ -734,18 +834,35 @@ export async function findTagFromReleases(
|
||||
owner: string,
|
||||
repo: string,
|
||||
tag: string,
|
||||
listingAttempts: number = 1,
|
||||
): Promise<Release | undefined> {
|
||||
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;
|
||||
if (error.status !== 404) {
|
||||
throw error;
|
||||
}
|
||||
// Re-throw unexpected errors
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (listingAttempts <= 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const attempts = Math.max(1, listingAttempts);
|
||||
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
||||
const recentReleases = await recentReleasesByTag(releaser, owner, repo, tag);
|
||||
const canonicalRelease = pickCanonicalRelease(recentReleases, undefined);
|
||||
if (canonicalRelease) {
|
||||
return canonicalRelease;
|
||||
}
|
||||
|
||||
if (attempt < attempts - 1) {
|
||||
await sleep(CREATED_RELEASE_DISCOVERY_RETRY_DELAY_MS);
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const CREATED_RELEASE_DISCOVERY_RETRY_DELAY_MS = 1000;
|
||||
@@ -797,33 +914,31 @@ function pickCanonicalRelease(
|
||||
})[0];
|
||||
}
|
||||
|
||||
async function cleanupDuplicateDraftReleases(
|
||||
async function cleanupCreatedDuplicateDraftRelease(
|
||||
releaser: Releaser,
|
||||
owner: string,
|
||||
repo: string,
|
||||
tag: string,
|
||||
canonicalReleaseId: number,
|
||||
releases: Release[],
|
||||
createdRelease: Release,
|
||||
): Promise<void> {
|
||||
const uniqueReleases = Array.from(
|
||||
new Map(releases.map((release) => [release.id, release])).values(),
|
||||
);
|
||||
if (
|
||||
createdRelease.id === canonicalReleaseId ||
|
||||
!createdRelease.draft ||
|
||||
createdRelease.assets.length > 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const duplicate of uniqueReleases) {
|
||||
if (duplicate.id === canonicalReleaseId || !duplicate.draft || duplicate.assets.length > 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`🧹 Removing duplicate draft release ${duplicate.id} for tag ${tag}...`);
|
||||
await releaser.deleteRelease({
|
||||
owner,
|
||||
repo,
|
||||
release_id: duplicate.id,
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn(`error deleting duplicate release ${duplicate.id}: ${error}`);
|
||||
}
|
||||
try {
|
||||
console.log(`🧹 Removing duplicate draft release ${createdRelease.id} for tag ${tag}...`);
|
||||
await releaser.deleteRelease({
|
||||
owner,
|
||||
repo,
|
||||
release_id: createdRelease.id,
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn(`error deleting duplicate release ${createdRelease.id}: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -840,7 +955,7 @@ async function canonicalizeCreatedRelease(
|
||||
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
||||
let releaseByTag: Release | undefined;
|
||||
try {
|
||||
releaseByTag = await findTagFromReleases(releaser, owner, repo, tag);
|
||||
releaseByTag = await findTagFromReleases(releaser, owner, repo, tag, 0);
|
||||
} catch (error) {
|
||||
console.warn(`error reloading release for tag ${tag}: ${error}`);
|
||||
}
|
||||
@@ -860,10 +975,19 @@ async function canonicalizeCreatedRelease(
|
||||
);
|
||||
}
|
||||
|
||||
await cleanupDuplicateDraftReleases(releaser, owner, repo, tag, canonicalRelease.id, [
|
||||
createdRelease,
|
||||
...recentReleases,
|
||||
]);
|
||||
const refreshedCreatedRelease = recentReleases.find(
|
||||
(release) => release.id === createdRelease.id,
|
||||
);
|
||||
if (refreshedCreatedRelease) {
|
||||
await cleanupCreatedDuplicateDraftRelease(
|
||||
releaser,
|
||||
owner,
|
||||
repo,
|
||||
tag,
|
||||
canonicalRelease.id,
|
||||
refreshedCreatedRelease,
|
||||
);
|
||||
}
|
||||
return canonicalRelease;
|
||||
}
|
||||
|
||||
@@ -933,12 +1057,16 @@ async function createRelease(
|
||||
release: canonicalRelease,
|
||||
created: canonicalRelease.id === createdRelease.data.id,
|
||||
};
|
||||
} catch (error) {
|
||||
} catch (error: unknown) {
|
||||
const githubError = error as {
|
||||
status?: number;
|
||||
response?: { data?: { errors?: Array<{ code?: string }> } };
|
||||
};
|
||||
// presume a race with competing matrix runs
|
||||
console.log(`⚠️ GitHub release failed with status: ${error.status}`);
|
||||
console.log(`${JSON.stringify(error.response.data)}`);
|
||||
console.log(`⚠️ GitHub release failed with status: ${githubError.status}`);
|
||||
console.log(errorMessage(error));
|
||||
|
||||
switch (error.status) {
|
||||
switch (githubError.status) {
|
||||
case 403:
|
||||
console.log(
|
||||
'Skip retry — your GitHub token/PAT does not have the required permission to create a release',
|
||||
@@ -946,12 +1074,13 @@ async function createRelease(
|
||||
throw error;
|
||||
|
||||
case 404:
|
||||
console.log('Skip retry - discussion category mismatch');
|
||||
throw error;
|
||||
const diagnostic = releaseCreation404Message(owner, repo, discussion_category_name, error);
|
||||
console.log(`Skip retry — ${diagnostic}`);
|
||||
throw new ReleaseCreationError(diagnostic, error);
|
||||
|
||||
case 422:
|
||||
// Check if this is a race condition with "already_exists" error
|
||||
const errorData = error.response?.data;
|
||||
const errorData = githubError.response?.data;
|
||||
if (errorData?.errors?.[0]?.code === 'already_exists') {
|
||||
console.log(
|
||||
'⚠️ Release already exists (race condition detected), retrying to find and update existing release...',
|
||||
|
||||
+2
-113
@@ -1,114 +1,3 @@
|
||||
import { setFailed, setOutput } from '@actions/core';
|
||||
import { getOctokit } from '@actions/github';
|
||||
import { GitHubReleaser, release, finalizeRelease, upload, listReleaseAssets } from './github';
|
||||
import { isTag, parseConfig, paths, unmatchedPatterns, uploadUrl } from './util';
|
||||
import { run } from './run';
|
||||
|
||||
import { env } from 'process';
|
||||
|
||||
async function run() {
|
||||
try {
|
||||
const config = parseConfig(env);
|
||||
if (!config.input_tag_name && !isTag(config.github_ref) && !config.input_draft) {
|
||||
throw new Error(`⚠️ GitHub Releases requires a tag`);
|
||||
}
|
||||
if (config.input_files) {
|
||||
const patterns = unmatchedPatterns(config.input_files, config.input_working_directory);
|
||||
patterns.forEach((pattern) => {
|
||||
if (config.input_fail_on_unmatched_files) {
|
||||
throw new Error(`⚠️ Pattern '${pattern}' does not match any files.`);
|
||||
} else {
|
||||
console.warn(`🤔 Pattern '${pattern}' does not match any files.`);
|
||||
}
|
||||
});
|
||||
if (patterns.length > 0 && config.input_fail_on_unmatched_files) {
|
||||
throw new Error(`⚠️ There were unmatched files`);
|
||||
}
|
||||
}
|
||||
|
||||
// const oktokit = GitHub.plugin(
|
||||
// require("@octokit/plugin-throttling"),
|
||||
// require("@octokit/plugin-retry")
|
||||
// );
|
||||
|
||||
const gh = getOctokit(config.github_token, {
|
||||
//new oktokit(
|
||||
throttle: {
|
||||
onRateLimit: (retryAfter, options) => {
|
||||
console.warn(`Request quota exhausted for request ${options.method} ${options.url}`);
|
||||
if (options.request.retryCount === 0) {
|
||||
// only retries once
|
||||
console.log(`Retrying after ${retryAfter} seconds!`);
|
||||
return true;
|
||||
}
|
||||
},
|
||||
onAbuseLimit: (retryAfter, options) => {
|
||||
// does not retry, only logs a warning
|
||||
console.warn(`Abuse detected for request ${options.method} ${options.url}`);
|
||||
},
|
||||
},
|
||||
});
|
||||
//);
|
||||
const releaser = new GitHubReleaser(gh);
|
||||
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);
|
||||
if (files.length == 0) {
|
||||
if (config.input_fail_on_unmatched_files) {
|
||||
throw new Error(`⚠️ ${config.input_files} does not include a valid file.`);
|
||||
} else {
|
||||
console.warn(`🤔 ${config.input_files} does not include a valid file.`);
|
||||
}
|
||||
}
|
||||
const currentAssets = rel.assets;
|
||||
|
||||
const uploadFile = async (path: string) => {
|
||||
const json = await upload(config, releaser, uploadUrl(rel.upload_url), path, currentAssets);
|
||||
return json ? (json.id as number) : undefined;
|
||||
};
|
||||
|
||||
let results: (number | undefined)[];
|
||||
if (!config.input_preserve_order) {
|
||||
results = await Promise.all(files.map(uploadFile));
|
||||
} else {
|
||||
results = [];
|
||||
for (const path of files) {
|
||||
results.push(await uploadFile(path));
|
||||
}
|
||||
}
|
||||
|
||||
uploadedAssetIds = new Set(results.filter((id): id is number => id !== undefined));
|
||||
}
|
||||
|
||||
console.log('Finalizing release...');
|
||||
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.
|
||||
console.log('Getting assets list...');
|
||||
{
|
||||
let assets: any[] = [];
|
||||
if (uploadedAssetIds.size > 0) {
|
||||
const updatedAssets = await listReleaseAssets(config, releaser, rel);
|
||||
assets = updatedAssets
|
||||
.filter((a) => uploadedAssetIds.has(a.id))
|
||||
.map((a) => {
|
||||
const { uploader, ...rest } = a;
|
||||
return rest;
|
||||
});
|
||||
}
|
||||
setOutput('assets', assets);
|
||||
}
|
||||
|
||||
console.log(`🎉 Release ready at ${rel.html_url}`);
|
||||
setOutput('url', rel.html_url);
|
||||
setOutput('id', rel.id.toString());
|
||||
setOutput('upload_url', rel.upload_url);
|
||||
} catch (error) {
|
||||
setFailed(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
run();
|
||||
void run();
|
||||
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
import { setFailed, setOutput } from '@actions/core';
|
||||
import { getOctokit } from '@actions/github';
|
||||
import { env } from 'process';
|
||||
import { GitHubReleaser, release, finalizeRelease, upload, listReleaseAssets } from './github';
|
||||
import { errorMessage, isTag, parseConfig, paths, unmatchedPatterns, uploadUrl } from './util';
|
||||
|
||||
export async function run(): Promise<void> {
|
||||
try {
|
||||
const config = parseConfig(env);
|
||||
if (!config.input_tag_name && !isTag(config.github_ref) && !config.input_draft) {
|
||||
throw new Error(`⚠️ GitHub Releases requires a tag`);
|
||||
}
|
||||
if (config.input_files) {
|
||||
const patterns = unmatchedPatterns(config.input_files, config.input_working_directory);
|
||||
patterns.forEach((pattern) => {
|
||||
if (config.input_fail_on_unmatched_files) {
|
||||
throw new Error(`⚠️ Pattern '${pattern}' does not match any files.`);
|
||||
} else {
|
||||
console.warn(`🤔 Pattern '${pattern}' does not match any files.`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const gh = getOctokit(config.github_token, {
|
||||
throttle: {
|
||||
onRateLimit: (retryAfter, options) => {
|
||||
console.warn(`Request quota exhausted for request ${options.method} ${options.url}`);
|
||||
if (options.request.retryCount === 0) {
|
||||
console.log(`Retrying after ${retryAfter} seconds!`);
|
||||
return true;
|
||||
}
|
||||
},
|
||||
onAbuseLimit: (_retryAfter, options) => {
|
||||
console.warn(`Abuse detected for request ${options.method} ${options.url}`);
|
||||
},
|
||||
},
|
||||
});
|
||||
const releaser = new GitHubReleaser(gh);
|
||||
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);
|
||||
if (files.length === 0) {
|
||||
if (config.input_fail_on_unmatched_files) {
|
||||
throw new Error(`⚠️ ${config.input_files} does not include a valid file.`);
|
||||
} else {
|
||||
console.warn(`🤔 ${config.input_files} does not include a valid file.`);
|
||||
}
|
||||
}
|
||||
const currentAssets = rel.assets;
|
||||
|
||||
const uploadFile = async (path: string) => {
|
||||
const json = await upload(
|
||||
config,
|
||||
releaser,
|
||||
uploadUrl(rel.upload_url),
|
||||
path,
|
||||
currentAssets,
|
||||
rel.id,
|
||||
);
|
||||
return json ? (json.id as number) : undefined;
|
||||
};
|
||||
|
||||
let results: (number | undefined)[];
|
||||
if (!config.input_preserve_order) {
|
||||
results = await Promise.all(files.map(uploadFile));
|
||||
} else {
|
||||
results = [];
|
||||
for (const path of files) {
|
||||
results.push(await uploadFile(path));
|
||||
}
|
||||
}
|
||||
|
||||
uploadedAssetIds = new Set(results.filter((id): id is number => id !== undefined));
|
||||
}
|
||||
|
||||
console.log('Finalizing release...');
|
||||
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.
|
||||
console.log('Getting assets list...');
|
||||
let assets: any[] = [];
|
||||
if (uploadedAssetIds.size > 0) {
|
||||
const updatedAssets = await listReleaseAssets(config, releaser, rel);
|
||||
assets = updatedAssets
|
||||
.filter((asset) => uploadedAssetIds.has(asset.id))
|
||||
.map((asset) => {
|
||||
const { uploader, ...rest } = asset;
|
||||
return rest;
|
||||
});
|
||||
}
|
||||
setOutput('assets', assets);
|
||||
|
||||
console.log(`🎉 Release ready at ${rel.html_url}`);
|
||||
setOutput('url', rel.html_url);
|
||||
setOutput('id', rel.id.toString());
|
||||
setOutput('upload_url', rel.upload_url);
|
||||
} catch (error: unknown) {
|
||||
setFailed(errorMessage(error));
|
||||
}
|
||||
}
|
||||
+18
@@ -28,6 +28,24 @@ export interface Config {
|
||||
input_make_latest: 'true' | 'false' | 'legacy' | undefined;
|
||||
}
|
||||
|
||||
export const errorMessage = (error: unknown): string => {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
if (
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
'message' in error &&
|
||||
typeof error.message === 'string'
|
||||
) {
|
||||
return error.message;
|
||||
}
|
||||
if (error === null || error === undefined) {
|
||||
return 'Unknown error';
|
||||
}
|
||||
return String(error);
|
||||
};
|
||||
|
||||
export const uploadUrl = (url: string): string => {
|
||||
const templateMarkerPos = url.indexOf('{');
|
||||
if (templateMarkerPos > -1) {
|
||||
|
||||
+8
-1
@@ -4,7 +4,14 @@ export default defineConfig({
|
||||
test: {
|
||||
environment: 'node',
|
||||
coverage: {
|
||||
reporter: ['text', 'lcov'],
|
||||
reporter: ['text', 'json-summary', 'lcov'],
|
||||
include: ['src/**/*.ts'],
|
||||
thresholds: {
|
||||
statements: 88,
|
||||
branches: 83,
|
||||
functions: 86,
|
||||
lines: 88,
|
||||
},
|
||||
},
|
||||
include: ['__tests__/**/*.ts'],
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user