Compare commits

...

7 Commits

Author SHA1 Message Date
Rui Chen 1c214ab373 fix: safely classify GitHub API errors (#822)
* refactor: harden GitHub error classification

Signed-off-by: Rui Chen <rui@chenrui.dev>

* test: define nested GitHub status handling

Signed-off-by: Rui Chen <rui@chenrui.dev>

---------

Signed-off-by: Rui Chen <rui@chenrui.dev>
2026-07-15 11:06:56 -04:00
Rui Chen d100e75a45 docs: mark v2 as unsupported (#821)
Signed-off-by: Rui Chen <rui@chenrui.dev>
2026-07-13 13:15:35 -04:00
Rui Chen 81bcd9dd59 test: cover release lookup after tag replacement (#820)
Signed-off-by: Rui Chen <rui@chenrui.dev>
2026-07-13 12:39:38 -04:00
Rui Chen b05950fac2 docs: update the release verification workflow (#819)
Signed-off-by: Rui Chen <rui@chenrui.dev>
2026-07-13 10:53:12 -04:00
Rui Chen 3d0d9888cb release 3.0.2 (#818)
Signed-off-by: Rui Chen <rui@chenrui.dev>
2026-07-13 10:24:38 -04:00
Rui Chen 7e13ed4ac5 fix: clarify release creation 404 errors (#817)
* fix: clarify release creation 404 errors

Signed-off-by: Rui Chen <rui@chenrui.dev>

* fix: clarify inaccessible release targets

Signed-off-by: Rui Chen <rui@chenrui.dev>

---------

Signed-off-by: Rui Chen <rui@chenrui.dev>
2026-07-13 10:02:44 -04:00
Rui Chen e6c70a53cf fix: replace existing release assets on Gitea (#816)
Signed-off-by: Rui Chen <rui@chenrui.dev>
2026-07-13 08:44:02 -04:00
14 changed files with 1349 additions and 136 deletions
+38 -3
View File
@@ -1,3 +1,35 @@
## 3.0.2
`3.0.2` is a patch release focused on release reliability and compatibility. It
reuses existing draft releases when publishing prereleases, supports replacing
release assets on Gitea, hardens streamed asset uploads, and provides clearer
release-creation diagnostics. It also includes TypeScript, coverage, and tooling
maintenance merged since `3.0.1`.
This release fixes #795, #438, and #803. The upload transport hardening covers the
historical failure reported in #790, although current hosted Node 24 runners did
not reproduce it naturally. The diagnostics work is related to #786 and does not
claim a reproducible release-creation fix.
## What's Changed
### Exciting New Features 🎉
* feat: improve release error reporting and test coverage by @chenrui333 in https://github.com/softprops/action-gh-release/pull/813
### Bug fixes 🐛
* fix: publish existing draft releases as prereleases by @godfengliang in https://github.com/softprops/action-gh-release/pull/801
* fix: upload small checksum assets reliably by @chenrui333 in https://github.com/softprops/action-gh-release/pull/815
* fix: replace existing release assets on Gitea by @chenrui333 in https://github.com/softprops/action-gh-release/pull/816
* fix: clarify release creation 404 errors by @chenrui333 in https://github.com/softprops/action-gh-release/pull/817
### Other Changes 🔄
* chore(deps): upgrade TypeScript to 7 by @chenrui333 in https://github.com/softprops/action-gh-release/pull/812
* chore(deps): remove unused TypeScript tooling by @chenrui333 in https://github.com/softprops/action-gh-release/pull/814
* dependency, Node 24 pin, and CI maintenance merged since `3.0.1`
## 3.0.1
- maintenance release with updated dependencies
@@ -6,8 +38,8 @@
`3.0.0` is a major release that moves the action runtime from Node 20 to Node 24.
Use `v3` on GitHub-hosted runners and self-hosted fleets that already support the
Node 24 Actions runtime. If you still need the last Node 20-compatible line, stay on
`v2.6.2`.
Node 24 Actions runtime. `v2.6.2` was the final Node 20-compatible release and is
no longer maintained or supported.
## What's Changed
@@ -15,10 +47,13 @@ Node 24 Actions runtime. If you still need the last Node 20-compatible line, sta
* Move the action runtime and bundle target to Node 24
* Update `@types/node` to the Node 24 line and allow future Dependabot updates
* Keep the floating major tag on `v3`; `v2` remains pinned to the latest `2.x` release
* Keep the floating major tag on `v3`; freeze `v2` at the final `v2.6.2` release
## 2.6.2
`2.6.2` is the final `v2` release and is no longer maintained or supported. Upgrade
to `v3` for the supported Node 24 runtime and current fixes.
## What's Changed
### Other Changes 🔄
+5 -2
View File
@@ -39,8 +39,11 @@ Typically usage of this action involves adding a step to a build that
is gated pushes to git tags. You may find `step.if` field helpful in accomplishing this
as it maximizes the reuse value of your workflow for non-tag pushes.
`v3` requires a GitHub Actions runtime that supports Node 24. If you still need the
last Node 20-compatible line, stay on `v2.6.2`.
`v2.6.2` is the final `v2` release and is no longer maintained or supported. It
uses the [Node 20 runtime deprecated by GitHub Actions](https://github.blog/changelog/2025-09-19-deprecation-of-node-20-on-github-actions-runners/).
Upgrade to `v3`, which runs on Node 24. If a problem still reproduces on the
latest `v3`, open a new issue with the exact action ref, workflow run URL,
runner, and relevant logs.
Below is a simple example of `step.if` tag gating
+23 -10
View File
@@ -23,21 +23,34 @@ Use this checklist when cutting a new `action-gh-release` release.
- `npm run build`
- `npm test`
5. Commit the release prep.
- Use a plain release commit message like `release 3.0.0`.
6. Create the annotated tag for the release commit.
- Example: `git tag -a v3.0.0 -m "v3.0.0"`
7. Push the commit and tag.
- Example: `git push origin master && git push origin v3.0.0`
8. Move the floating major tag to the new release tag.
- Use `git commit -s` so the release commit carries a DCO sign-off.
- Use a plain release commit message like `release X.Y.Z`.
6. Push a release branch and open a pull request against `master`.
- Wait for required checks and reviews.
- Do not bypass branch protection or tag an unmerged release branch.
7. After merge, fetch `origin/master` and resolve the exact merged release commit.
- Confirm that commit contains the expected package version and top changelog entry.
- When the PR is squash-merged, do not assume the release branch commit is the release commit.
8. Create and push the full annotated version tag on the merged release commit.
- Example: `git tag -a vX.Y.Z -m "vX.Y.Z" <release-commit>`
- Push only the full version tag first, then wait for its tag-triggered CI to pass.
9. Move the floating major tag to the same merged release commit.
- For the current major line, run `npm run updatetag` to move `v3`.
- Keep `v2` pinned to the latest `2.x` release for consumers that still need the Node 20 runtime.
- Verify the floating tag points at the same commit as the new full tag.
9. Create the GitHub release from the new tag.
- Do not move `v2`; it is frozen at the final, unsupported `v2.6.2` release.
- Verify `v3` and the full version tag are annotated and peel to the same commit.
- Verify `v2` did not move, then wait for the separate `v3` tag-triggered CI run to pass.
10. Create the GitHub release from the full version tag.
- Prefer the release body from [CHANGELOG.md](CHANGELOG.md), then let GitHub append generated notes only if they add value.
- Verify the release shows the expected tag, title, notes, and attached artifacts.
- Verify the release shows the expected tag, title, notes, draft/prerelease state, and attached artifacts.
11. Run post-release consumer verification in `ruitest2/action-gh-release-test`.
- Run the generic smoke against both the full version tag and `v3`.
- Run regression workflows relevant to the fixes in the release.
- Confirm that every disposable release, tag, discussion, container, volume, and temporary credential was cleaned up.
## Notes
- Behavior changes should already have matching updates in [README.md](README.md), [action.yml](action.yml), tests, and `dist/index.js` before release prep begins.
- Docs-only releases still need an intentional changelog entry and version bump decision.
- If a release is mainly bug fixes, keep the title and summary patch-oriented; do not bury the actual fixes under dependency noise.
- Do not move the floating major tag or publish the GitHub release until the full
version tag's CI passes.
+780 -37
View File
@@ -149,6 +149,55 @@ describe('github', () => {
expect(pageAfterMatch).not.toHaveBeenCalled();
});
it.each([
['a top-level 404', { status: 404 }],
['a nested response 404', { response: { status: 404 } }],
[
'an invalid top-level status with a nested response 404',
{ status: '500', response: { status: 404 } },
],
])('uses release-list fallback for %s', async (_name, lookupError) => {
const draftRelease = { ...mockRelease, draft: true };
const allReleases = vi.fn(async function* () {
yield { data: [draftRelease] };
});
const releaser = {
...mockReleaser,
getReleaseByTag: vi.fn().mockRejectedValue(lookupError),
allReleases,
};
await expect(findTagFromReleases(releaser, owner, repo, draftRelease.tag_name)).resolves.toBe(
draftRelease,
);
expect(allReleases).toHaveBeenCalledOnce();
});
it.each([
['a nested response 500', { response: { status: 500 } }],
['a string top-level status', { status: '404' }],
['a string nested response status', { response: { status: '404' } }],
['a status-less object', { reason: 'lookup failed' }],
[
'a numeric top-level 500 with a nested response 404',
{ status: 500, response: { status: 404 } },
],
])('does not use release-list fallback for %s', async (_name, lookupError) => {
const allReleases = vi.fn(async function* () {
yield { data: [mockRelease] };
});
const releaser = {
...mockReleaser,
getReleaseByTag: vi.fn().mockRejectedValue(lookupError),
allReleases,
};
await expect(findTagFromReleases(releaser, owner, repo, mockRelease.tag_name)).rejects.toBe(
lookupError,
);
expect(allReleases).not.toHaveBeenCalled();
});
it('does not exhaust pagination while checking a brand-new tag', async () => {
let pagesRead = 0;
const releaser = {
@@ -322,6 +371,140 @@ describe('github', () => {
});
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.each([
{
name: 'top-level status',
deletionError: { status: 404, message: 'standard route not found' },
},
{
name: 'nested response status',
deletionError: {
response: { status: 404 },
message: 'standard route not found',
},
},
])('falls back to release-scoped asset deletion after a 404 with $name', async (testCase) => {
const deleteReleaseAsset = vi.fn().mockRejectedValue(testCase.deletionError);
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.each([
['a string', 'not found'],
['null', null],
['an arbitrary object', { response: { data: 'not found' } }],
])('does not misclassify %s as a fallback 404', async (_name, deletionError) => {
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('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: {
@@ -689,9 +872,236 @@ describe('github', () => {
expect(finalizeReleaseSpy).toHaveBeenCalledTimes(1);
expect(deleteReleaseSpy).not.toHaveBeenCalled();
});
it('retries malformed tag-rule errors without deleting the draft', async () => {
const finalizeReleaseSpy = vi.fn().mockRejectedValue({
status: 422,
response: {
data: {
errors: [null, 'invalid', { field: 'pre_receive', message: 42 }],
},
},
});
const deleteReleaseSpy = vi.fn();
const releaser = createReleaser({
finalizeRelease: finalizeReleaseSpy,
deleteRelease: deleteReleaseSpy,
});
await expect(
finalizeRelease(
{
...config,
input_draft: false,
},
releaser,
draftRelease,
true,
1,
),
).rejects.toThrow('Too many retries.');
expect(finalizeReleaseSpy).toHaveBeenCalledOnce();
expect(deleteReleaseSpy).not.toHaveBeenCalled();
});
it('finds a valid tag-rule violation after malformed validation entries', async () => {
const finalizeReleaseSpy = vi.fn().mockRejectedValue({
response: {
status: 422,
data: {
errors: [
null,
'invalid',
{
field: 'pre_receive',
message: 'Cannot create ref due to creations being restricted.',
},
],
},
},
});
const deleteReleaseSpy = vi.fn().mockResolvedValue(undefined);
const releaser = createReleaser({
finalizeRelease: finalizeReleaseSpy,
deleteRelease: deleteReleaseSpy,
});
await expect(
finalizeRelease(
{
...config,
input_draft: false,
},
releaser,
draftRelease,
true,
1,
),
).rejects.toThrow(
'Tag creation for v1.0.0 is blocked by repository rules. Deleted draft release 1 to avoid leaving an orphaned draft release.',
);
expect(finalizeReleaseSpy).toHaveBeenCalledOnce();
expect(deleteReleaseSpy).toHaveBeenCalledWith({
owner: 'owner',
repo: 'repo',
release_id: draftRelease.id,
});
});
});
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.each([
{
name: 'a nested response 404 as repository access failure',
listingError: {
response: { status: 404 },
message: 'Not Found - list-releases',
},
expectAccessError: true,
},
{
name: 'a nested response 500 as the original error',
listingError: {
response: { status: 500 },
message: 'Server Error - list-releases',
},
expectAccessError: false,
},
{
name: 'a status-less object as the original error',
listingError: { reason: 'release listing failed' },
expectAccessError: false,
},
])('classifies a release-list fallback error with $name', async (testCase) => {
const createRelease = vi.fn();
const releaser = createReleaser({
getReleaseByTag: vi.fn().mockRejectedValue({ status: 404 }),
allReleases: async function* () {
throw testCase.listingError;
},
createRelease,
});
const thrown = await release(config, releaser, 1).catch((error) => error);
if (testCase.expectAccessError) {
expect(thrown).toMatchObject({
name: 'ReleaseAccessError',
status: 404,
cause: testCase.listingError,
});
} else {
expect(thrown).toBe(testCase.listingError);
}
expect(createRelease).not.toHaveBeenCalled();
});
it('reports a useful create error without assuming response data exists', async () => {
const releaseError = {
status: 403,
@@ -712,6 +1122,100 @@ describe('github', () => {
expect(log).not.toHaveBeenCalledWith('undefined');
});
it.each([
{
name: 'a nested response 403',
releaseError: {
response: { status: 403 },
message: 'Resource not accessible by integration',
},
},
{
name: 'a nested response 422 without already_exists',
releaseError: {
response: { status: 422, data: { errors: [{ code: 'invalid' }] } },
message: 'Validation Failed',
},
},
])('does not retry release creation for $name', async (testCase) => {
const createRelease = vi.fn().mockRejectedValue(testCase.releaseError);
const releaser = createReleaser({
getReleaseByTag: vi.fn().mockRejectedValue({ status: 404 }),
createRelease,
allReleases: async function* () {
yield { data: [] };
},
});
await expect(release(config, releaser, 1)).rejects.toBe(testCase.releaseError);
expect(createRelease).toHaveBeenCalledOnce();
});
it('classifies a nested response 404 from release creation without retrying', async () => {
const releaseError = {
response: { status: 404 },
message: 'Not Found - create-a-release',
};
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, 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');
expect(createRelease).toHaveBeenCalledOnce();
});
it.each([
['non-object response data', { status: 422, response: { data: 'invalid' } }],
['missing validation errors', { status: 422, response: { data: {} } }],
['malformed validation errors', { status: 422, response: { data: { errors: [null] } } }],
])('does not retry a permanent 422 with %s', async (_name, releaseError) => {
const createRelease = vi.fn().mockRejectedValue(releaseError);
const releaser = createReleaser({
getReleaseByTag: vi.fn().mockRejectedValue({ status: 404 }),
createRelease,
allReleases: async function* () {
yield { data: [] };
},
});
await expect(release(config, releaser, 1)).rejects.toBe(releaseError);
expect(createRelease).toHaveBeenCalledOnce();
});
it.each([
['a string', 'transport failed'],
['null', null],
['an arbitrary object', { reason: 'transport failed' }],
['a nested response 500', { response: { status: 500 } }],
])(
'retries unclassified release creation failures without secondary TypeErrors when the error is %s',
async (_name, releaseError) => {
const createRelease = vi.fn().mockRejectedValue(releaseError);
const releaser = createReleaser({
getReleaseByTag: vi.fn().mockRejectedValue({ status: 404 }),
createRelease,
allReleases: async function* () {
yield { data: [] };
},
});
await expect(release(config, releaser, 1)).rejects.toThrow('Too many retries.');
expect(createRelease).toHaveBeenCalledOnce();
},
);
it('passes previous_tag_name through when creating a release with generated notes', async () => {
const createReleaseSpy = vi.fn(async () => ({
data: {
@@ -858,6 +1362,154 @@ describe('github', () => {
);
});
it('reuses a published prerelease found by listing after direct tag lookup returns 404', async () => {
const existingRelease: Release = {
id: 77,
upload_url: 'existing-upload',
html_url: 'existing-html',
tag_name: 'v1.0.0',
name: 'nightly',
body: 'previous nightly body',
target_commitish: 'old-commit',
draft: false,
prerelease: true,
assets: [{ id: 5, name: 'nightly.zip' }],
};
const updatedRelease: Release = {
...existingRelease,
name: 'updated nightly',
body: 'updated nightly body',
target_commitish: 'new-commit',
};
const createRelease = unexpected('createRelease');
const updateRelease = vi.fn().mockResolvedValue({ data: updatedRelease });
const releaser = createReleaser({
getReleaseByTag: vi.fn().mockRejectedValue({ status: 404 }),
allReleases: async function* () {
yield { data: [existingRelease] };
},
createRelease,
updateRelease,
});
const result = await release(
{
...config,
input_name: 'updated nightly',
input_body: 'updated nightly body',
input_prerelease: true,
input_draft: false,
input_target_commitish: 'new-commit',
},
releaser,
);
expect(result).toEqual({ release: updatedRelease, created: false });
expect(updateRelease).toHaveBeenCalledWith(
expect.objectContaining({
release_id: existingRelease.id,
tag_name: existingRelease.tag_name,
target_commitish: 'new-commit',
draft: false,
prerelease: true,
}),
);
expect(createRelease).not.toHaveBeenCalled();
});
it('creates a replacement after an existing-release update returns a nested response 404', async () => {
const existingRelease: Release = {
id: 41,
upload_url: 'existing-upload',
html_url: 'existing-html',
tag_name: 'v1.0.0',
name: 'existing release',
body: 'existing body',
target_commitish: 'main',
draft: false,
prerelease: false,
assets: [],
};
const replacementRelease: Release = {
...existingRelease,
id: 42,
upload_url: 'replacement-upload',
html_url: 'replacement-html',
name: 'v1.0.0',
body: undefined,
draft: true,
};
const updateError = { response: { status: 404 }, message: 'release disappeared' };
const getReleaseByTag = vi
.fn()
.mockResolvedValueOnce({ data: existingRelease })
.mockResolvedValue({ data: replacementRelease });
const updateRelease = vi.fn().mockRejectedValue(updateError);
const createRelease = vi.fn().mockResolvedValue({ data: replacementRelease });
const releaser = createReleaser({
getReleaseByTag,
updateRelease,
createRelease,
allReleases: async function* () {
yield { data: [replacementRelease] };
},
});
await expect(release(config, releaser, 1)).resolves.toEqual({
release: replacementRelease,
created: true,
});
expect(updateRelease).toHaveBeenCalledOnce();
expect(createRelease).toHaveBeenCalledOnce();
expect(createRelease).toHaveBeenCalledWith({
owner: 'owner',
repo: 'repo',
tag_name: 'v1.0.0',
name: 'v1.0.0',
body: undefined,
draft: true,
prerelease: undefined,
target_commitish: undefined,
discussion_category_name: undefined,
generate_release_notes: false,
make_latest: undefined,
previous_tag_name: undefined,
});
expect(getReleaseByTag).toHaveBeenCalledTimes(2);
});
it.each([
['a nested response 403', { response: { status: 403 }, message: 'forbidden' }],
['a nested response 500', { response: { status: 500 }, message: 'server error' }],
])(
'does not create a replacement after an existing-release update returns %s',
async (_name, updateError) => {
const existingRelease: Release = {
id: 41,
upload_url: 'existing-upload',
html_url: 'existing-html',
tag_name: 'v1.0.0',
name: 'existing release',
body: 'existing body',
target_commitish: 'main',
draft: false,
prerelease: false,
assets: [],
};
const createRelease = vi.fn();
const updateRelease = vi.fn().mockRejectedValue(updateError);
const releaser = createReleaser({
getReleaseByTag: vi.fn().mockResolvedValue({ data: existingRelease }),
updateRelease,
createRelease,
});
await expect(release(config, releaser, 1)).rejects.toBe(updateError);
expect(updateRelease).toHaveBeenCalledOnce();
expect(createRelease).not.toHaveBeenCalled();
},
);
it.each([
['an omitted draft input', undefined],
['a null-expression draft input', undefined],
@@ -945,6 +1597,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' });
@@ -956,24 +1609,83 @@ describe('github', () => {
expect(deleteReleaseAsset).toHaveBeenCalledWith({
owner: 'owner',
repo: 'repo',
release_id: 1,
asset_id: 99,
});
expect(uploadReleaseAsset).toHaveBeenCalledTimes(2);
});
it('surfaces an actionable immutable-release error for prerelease uploads', async () => {
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.each([
{
name: 'standard release with a nested status',
prerelease: undefined,
uploadError: {
response: { status: 422 },
message: 'Cannot upload assets to an immutable release.',
},
expected:
'Cannot upload asset draft-false.txt to an immutable release. GitHub only allows asset uploads before a release is published, so upload assets to a draft release before you publish it.',
},
{
name: 'prerelease with response data',
prerelease: true,
uploadError: {
status: 422,
response: {
data: {
message: 'Cannot upload assets to an immutable release.',
},
},
},
expected:
'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.',
},
])('surfaces an actionable immutable-release error for a $name', async (testCase) => {
const tempDir = mkdtempSync(join(tmpdir(), 'gh-release-immutable-'));
const assetPath = join(tempDir, 'draft-false.txt');
writeFileSync(assetPath, 'hello');
const uploadReleaseAsset = vi.fn().mockRejectedValue({
status: 422,
response: {
data: {
message: 'Cannot upload assets to an immutable release.',
},
},
});
const uploadReleaseAsset = vi.fn().mockRejectedValue(testCase.uploadError);
const mockReleaser: Releaser = {
getReleaseByTag: () => Promise.reject('Not implemented'),
@@ -990,22 +1702,23 @@ describe('github', () => {
uploadReleaseAsset,
};
await expect(
upload(
{
...config,
input_prerelease: true,
},
mockReleaser,
'https://uploads.github.com/repos/owner/repo/releases/1/assets',
assetPath,
[],
),
).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.',
);
rmSync(tempDir, { recursive: true, force: true });
try {
await expect(
upload(
{
...config,
input_prerelease: testCase.prerelease,
},
mockReleaser,
'https://uploads.github.com/repos/owner/repo/releases/1/assets',
assetPath,
[],
1,
),
).rejects.toThrow(testCase.expected);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
it('retries upload after deleting a conflicting renamed asset matched by label', async () => {
@@ -1054,6 +1767,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' });
@@ -1065,6 +1779,7 @@ describe('github', () => {
expect(deleteReleaseAsset).toHaveBeenCalledWith({
owner: 'owner',
repo: 'repo',
release_id: 1,
asset_id: 99,
});
expect(updateReleaseAsset).toHaveBeenCalledWith({
@@ -1080,7 +1795,24 @@ describe('github', () => {
}
});
it('handles 422 already_exists error gracefully', async () => {
it.each([
{
name: 'top-level status',
releaseError: {
status: 422,
response: { data: { errors: [{ code: 'already_exists' }] } },
},
},
{
name: 'nested response status',
releaseError: {
response: {
status: 422,
data: { errors: [{ code: 'already_exists' }] },
},
},
},
])('retries a 422 already_exists race with $name', async (testCase) => {
vi.useFakeTimers();
const existingRelease = {
id: 1,
@@ -1106,10 +1838,7 @@ describe('github', () => {
},
createRelease: () => {
createAttempts++;
return Promise.reject({
status: 422,
response: { data: { errors: [{ code: 'already_exists' }] } },
});
return Promise.reject(testCase.releaseError);
},
updateRelease: () =>
Promise.resolve({
@@ -1142,6 +1871,7 @@ describe('github', () => {
assert.ok(result);
assert.equal(result.release.id, 1);
assert.equal(result.created, false);
expect(createAttempts).toBe(1);
});
it('normalizes refs/tags-prefixed input_tag_name values before reusing an existing release', async () => {
@@ -1512,6 +2242,7 @@ describe('github', () => {
'https://uploads.example.test/assets',
dotfilePath,
[],
1,
);
expect(updateReleaseAssetSpy).toHaveBeenCalledWith({
@@ -1583,6 +2314,7 @@ describe('github', () => {
'https://uploads.github.com/repos/owner/repo/releases/1/assets',
dotfilePath,
[],
1,
);
expect(updateReleaseAssetSpy).toHaveBeenNthCalledWith(1, {
@@ -1653,6 +2385,7 @@ describe('github', () => {
'https://uploads.github.com/repos/owner/repo/releases/1/assets',
dotfilePath,
[],
1,
);
expect(listReleaseAssetsSpy).toHaveBeenCalledWith({
@@ -1712,6 +2445,7 @@ describe('github', () => {
'https://uploads.github.com/repos/owner/repo/releases/1/assets',
dotfilePath,
[],
1,
);
expect(listReleaseAssetsSpy).toHaveBeenCalledWith({
@@ -1772,6 +2506,7 @@ describe('github', () => {
'https://uploads.github.com/repos/owner/repo/releases/1/assets',
dotfilePath,
[],
1,
);
await vi.waitFor(() => expect(listReleaseAssetsSpy).toHaveBeenCalledTimes(1));
@@ -1827,18 +2562,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 });
+182
View File
@@ -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');
}
}
});
});
+49
View File
@@ -211,6 +211,7 @@ describe('run', () => {
'https://uploads.example.test/releases/41/assets',
'fixture/dist/action.zip',
initialRelease.assets,
initialRelease.id,
);
});
@@ -285,6 +286,54 @@ describe('run', () => {
);
});
it('uses the selected moving-tag release ID for upload, finalization, and outputs', async () => {
config = {
...config,
input_tag_name: 'nightly',
input_draft: false,
input_prerelease: true,
input_files: ['nightly.zip'],
};
const selectedRelease: Release = {
...initialRelease,
id: 77,
upload_url: 'https://uploads.example.test/releases/77/assets{?name,label}',
html_url: 'https://example.test/releases/77',
tag_name: 'nightly',
name: 'nightly',
prerelease: true,
};
const publishedRelease: Release = {
...selectedRelease,
draft: false,
};
mocks.release.mockResolvedValue({ release: selectedRelease, created: false });
mocks.paths.mockReturnValue(['nightly.zip']);
mocks.upload.mockResolvedValue({ id: 9 });
mocks.finalizeRelease.mockResolvedValue(publishedRelease);
mocks.listReleaseAssets.mockResolvedValue([{ id: 9, name: 'nightly.zip' }]);
await run();
expect(mocks.upload).toHaveBeenCalledWith(
config,
mocks.releaser,
'https://uploads.example.test/releases/77/assets',
'nightly.zip',
selectedRelease.assets,
77,
);
expect(mocks.finalizeRelease).toHaveBeenCalledWith(
config,
mocks.releaser,
selectedRelease,
false,
);
expect(mocks.listReleaseAssets).toHaveBeenCalledWith(config, mocks.releaser, publishedRelease);
expect(mocks.setOutput).toHaveBeenCalledWith('id', '77');
expect(mocks.setOutput).toHaveBeenCalledWith('url', publishedRelease.html_url);
});
it('leaves an existing draft recoverable when an upload fails', async () => {
config = {
...config,
+79 -5
View File
@@ -205,7 +205,7 @@ describe('release asset upload transport', () => {
openFile.mockClear();
try {
await expect(upload(config, releaser, uploadUrl, fixture.path, [])).resolves.toEqual({
await expect(upload(config, releaser, uploadUrl, fixture.path, [], 1)).resolves.toEqual({
id: 123,
name: fixture.name,
});
@@ -243,7 +243,7 @@ describe('release asset upload transport', () => {
openFile.mockClear();
try {
await expect(upload(config, releaser, uploadUrl, fixture.path, [])).rejects.toThrow(
await expect(upload(config, releaser, uploadUrl, fixture.path, [], 1)).rejects.toThrow(
'Validation Failed',
);
await expectLastFileHandleClosed();
@@ -275,7 +275,7 @@ describe('release asset upload transport', () => {
});
try {
await expect(upload(config, releaser, uploadUrl, fixture.path, [])).resolves.toEqual({
await expect(upload(config, releaser, uploadUrl, fixture.path, [], 1)).resolves.toEqual({
id: 123,
name: fixture.name,
});
@@ -297,11 +297,13 @@ describe('release asset upload transport', () => {
);
const releaser = new GitHubReleaser(getOctokit(config.github_token));
vi.spyOn(releaser, 'listReleaseAssets').mockResolvedValue([{ id: 9, name: fixture.name }]);
vi.spyOn(releaser, 'deleteReleaseAsset').mockResolvedValue(undefined);
const deleteReleaseAsset = vi
.spyOn(releaser, 'deleteReleaseAsset')
.mockResolvedValue(undefined);
openFile.mockClear();
try {
await expect(upload(config, releaser, uploadUrl, fixture.path, [])).resolves.toEqual({
await expect(upload(config, releaser, uploadUrl, fixture.path, [], 1)).resolves.toEqual({
id: 123,
name: fixture.name,
});
@@ -309,6 +311,12 @@ describe('release asset upload transport', () => {
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' });
@@ -317,4 +325,70 @@ describe('release asset upload transport', () => {
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);
}
});
});
+30 -30
View File
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "action-gh-release",
"version": "3.0.1",
"version": "3.0.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "action-gh-release",
"version": "3.0.1",
"version": "3.0.2",
"dependencies": {
"@actions/core": "^3.0.1",
"@actions/github": "^9.1.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "action-gh-release",
"version": "3.0.1",
"version": "3.0.2",
"private": true,
"description": "GitHub Action for creating GitHub Releases",
"main": "lib/main.js",
+147 -40
View File
@@ -9,6 +9,43 @@ type GitHub = InstanceType<typeof GitHub>;
type UploadChunk = ArrayBuffer | Uint8Array<ArrayBufferLike>;
type UploadBody = ReadableStream<Uint8Array<ArrayBufferLike>>;
type UnknownRecord = Record<string, unknown>;
const asRecord = (value: unknown): UnknownRecord | undefined =>
typeof value === 'object' && value !== null ? (value as UnknownRecord) : undefined;
const getErrorStatus = (error: unknown): number | undefined => {
const errorRecord = asRecord(error);
if (typeof errorRecord?.status === 'number') {
return errorRecord.status;
}
const response = asRecord(errorRecord?.response);
return typeof response?.status === 'number' ? response.status : undefined;
};
const getResponseData = (error: unknown): UnknownRecord | undefined => {
const response = asRecord(asRecord(error)?.response);
return asRecord(response?.data);
};
const getErrorMessage = (error: unknown): string | undefined => {
const message = asRecord(error)?.message;
return typeof message === 'string' ? message : undefined;
};
const getRequestUrl = (error: unknown): string | undefined => {
const request = asRecord(asRecord(error)?.request);
return typeof request?.url === 'string' ? request.url : undefined;
};
const getValidationErrors = (error: unknown): unknown[] => {
const errors = getResponseData(error)?.errors;
return Array.isArray(errors) ? errors : [];
};
const hasValidationErrorCode = (error: unknown, code: string): boolean =>
asRecord(getValidationErrors(error)[0])?.code === code;
const fileUploadStream = (fileHandle: FileHandle): UploadBody => {
const source = fileHandle.readableWebStream() as ReadableStream<UploadChunk>;
@@ -68,6 +105,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 }>;
@@ -96,7 +170,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>;
@@ -229,9 +308,31 @@ 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) {
if (getErrorStatus(error) !== 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> {
@@ -285,10 +386,10 @@ const releaseAssetMatchesName = (
asset: { name: string; label?: string | null },
): boolean => asset.name === name || asset.name === alignAssetName(name) || asset.label === name;
const isReleaseAssetUpdateNotFound = (error: any): boolean => {
const errorStatus = error?.status ?? error?.response?.status;
const requestUrl = error?.request?.url;
const errorMessage = error?.message;
const isReleaseAssetUpdateNotFound = (error: unknown): boolean => {
const errorStatus = getErrorStatus(error);
const requestUrl = getRequestUrl(error);
const message = getErrorMessage(error);
const isReleaseAssetRequest =
typeof requestUrl === 'string' &&
(/\/releases\/assets\//.test(requestUrl) || /\/releases\/\d+\/assets(?:\?|$)/.test(requestUrl));
@@ -296,15 +397,15 @@ const isReleaseAssetUpdateNotFound = (error: any): boolean => {
return (
errorStatus === 404 &&
(isReleaseAssetRequest ||
(typeof errorMessage === 'string' && errorMessage.includes('update-a-release-asset')))
(typeof message === 'string' && message.includes('update-a-release-asset')))
);
};
const isImmutableReleaseAssetUploadFailure = (error: any): boolean => {
const errorStatus = error?.status ?? error?.response?.status;
const errorMessage = error?.response?.data?.message ?? error?.message;
const isImmutableReleaseAssetUploadFailure = (error: unknown): boolean => {
const errorStatus = getErrorStatus(error);
const message = getResponseData(error)?.message ?? getErrorMessage(error);
return errorStatus === 422 && /immutable release/i.test(String(errorMessage));
return errorStatus === 422 && /immutable release/i.test(String(message));
};
const immutableReleaseAssetUploadMessage = (
@@ -321,11 +422,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.
@@ -341,6 +441,7 @@ export const upload = async (
asset_id: currentAsset.id || 1,
owner,
repo,
release_id: releaseId,
});
}
}
@@ -413,8 +514,8 @@ export const upload = async (
try {
return await updateAssetLabel(uploadedAsset.id);
} catch (error: any) {
const errorStatus = error?.status ?? error?.response?.status;
} catch (error: unknown) {
const errorStatus = getErrorStatus(error);
if (errorStatus === 404 && releaseId !== undefined) {
try {
@@ -451,9 +552,8 @@ export const upload = async (
try {
return await handleUploadedAsset(await uploadAsset());
} catch (error: any) {
const errorStatus = error?.status ?? error?.response?.status;
const errorData = error?.response?.data;
} catch (error: unknown) {
const errorStatus = getErrorStatus(error);
if (isImmutableReleaseAssetUploadFailure(error)) {
throw new Error(immutableReleaseAssetUploadMessage(name, config.input_prerelease));
@@ -481,7 +581,7 @@ export const upload = async (
if (
config.input_overwrite_files !== false &&
errorStatus === 422 &&
errorData?.errors?.[0]?.code === 'already_exists' &&
hasValidationErrorCode(error, 'already_exists') &&
releaseId !== undefined
) {
console.log(
@@ -499,6 +599,7 @@ export const upload = async (
await releaser.deleteReleaseAsset({
owner,
repo,
release_id: releaseId,
asset_id: latestAsset.id,
});
return await handleUploadedAsset(await uploadAsset());
@@ -535,6 +636,11 @@ export const release = async (
try {
_release = await findTagFromReleases(releaser, owner, repo, tag, maxRetries);
} catch (error) {
if (getErrorStatus(error) === 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}`,
);
@@ -613,7 +719,10 @@ export const release = async (
created: false,
};
} catch (error) {
if (error.status !== 404) {
if (error instanceof ReleaseCreationError) {
throw error;
}
if (getErrorStatus(error) !== 404) {
console.log(
`⚠️ Unexpected error fetching GitHub release for tag ${config.github_ref}: ${error}`,
);
@@ -764,7 +873,7 @@ export async function findTagFromReleases(
const { data: release } = await releaser.getReleaseByTag({ owner, repo, tag });
return release;
} catch (error) {
if (error.status !== 404) {
if (getErrorStatus(error) !== 404) {
throw error;
}
}
@@ -982,15 +1091,12 @@ async function createRelease(
created: canonicalRelease.id === createdRelease.data.id,
};
} catch (error: unknown) {
const githubError = error as {
status?: number;
response?: { data?: { errors?: Array<{ code?: string }> } };
};
const errorStatus = getErrorStatus(error);
// presume a race with competing matrix runs
console.log(`⚠️ GitHub release failed with status: ${githubError.status}`);
console.log(`⚠️ GitHub release failed with status: ${errorStatus}`);
console.log(errorMessage(error));
switch (githubError.status) {
switch (errorStatus) {
case 403:
console.log(
'Skip retry — your GitHub token/PAT does not have the required permission to create a release',
@@ -998,13 +1104,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 = githubError.response?.data;
if (errorData?.errors?.[0]?.code === 'already_exists') {
if (hasValidationErrorCode(error, 'already_exists')) {
console.log(
'⚠️ Release already exists (race condition detected), retrying to find and update existing release...',
);
@@ -1021,16 +1127,17 @@ async function createRelease(
}
}
function isTagCreationBlockedError(error: any): boolean {
const errors = error?.response?.data?.errors;
if (!Array.isArray(errors) || error?.status !== 422) {
function isTagCreationBlockedError(error: unknown): boolean {
if (getErrorStatus(error) !== 422) {
return false;
}
return errors.some(
({ field, message }: { field?: string; message?: string }) =>
field === 'pre_receive' &&
typeof message === 'string' &&
message.includes('creations being restricted'),
);
return getValidationErrors(error).some((validationError) => {
const errorRecord = asRecord(validationError);
return (
errorRecord?.field === 'pre_receive' &&
typeof errorRecord.message === 'string' &&
errorRecord.message.includes('creations being restricted')
);
});
}
+8 -1
View File
@@ -52,7 +52,14 @@ export async function run(): Promise<void> {
const currentAssets = rel.assets;
const uploadFile = async (path: string) => {
const json = await upload(config, releaser, uploadUrl(rel.upload_url), path, currentAssets);
const json = await upload(
config,
releaser,
uploadUrl(rel.upload_url),
path,
currentAssets,
rel.id,
);
return json ? (json.id as number) : undefined;
};
+1 -1
View File
@@ -1,6 +1,6 @@
{
"compilerOptions": {
"useUnknownInCatchVariables": false,
"useUnknownInCatchVariables": true,
/* Basic Options */
// "incremental": true, /* Enable incremental compilation */
"target": "es2022",
+4 -4
View File
@@ -7,10 +7,10 @@ export default defineConfig({
reporter: ['text', 'json-summary', 'lcov'],
include: ['src/**/*.ts'],
thresholds: {
statements: 88,
branches: 83,
functions: 86,
lines: 88,
statements: 94,
branches: 90,
functions: 95,
lines: 94,
},
},
include: ['__tests__/**/*.ts'],