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
17 Commits
718ea10b13
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 1c214ab373 | |||
| d100e75a45 | |||
| 81bcd9dd59 | |||
| b05950fac2 | |||
| 3d0d9888cb | |||
| 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.
|
||||
|
||||
+38
-3
@@ -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 🔄
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
+1154
-57
File diff suppressed because it is too large
Load Diff
@@ -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,433 @@
|
||||
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('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,
|
||||
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
+544
-423
File diff suppressed because it is too large
Load Diff
+4
-7
@@ -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",
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
+248
-89
@@ -1,12 +1,63 @@
|
||||
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>>;
|
||||
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>;
|
||||
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 +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 }>;
|
||||
|
||||
@@ -82,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>;
|
||||
|
||||
@@ -99,7 +192,7 @@ export interface Releaser {
|
||||
size: number;
|
||||
mime: string;
|
||||
token: string;
|
||||
data: any;
|
||||
data: UploadBody;
|
||||
}): Promise<{ status: number; data: any }>;
|
||||
}
|
||||
|
||||
@@ -215,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> {
|
||||
@@ -239,7 +354,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',
|
||||
@@ -271,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));
|
||||
@@ -282,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 = (
|
||||
@@ -307,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.
|
||||
@@ -327,6 +441,7 @@ export const upload = async (
|
||||
asset_id: currentAsset.id || 1,
|
||||
owner,
|
||||
repo,
|
||||
release_id: releaseId,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -367,7 +482,7 @@ export const upload = async (
|
||||
size,
|
||||
mime,
|
||||
token: config.github_token,
|
||||
data: fh.readableWebStream(),
|
||||
data: fileUploadStream(fh),
|
||||
});
|
||||
} finally {
|
||||
await fh.close();
|
||||
@@ -399,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 {
|
||||
@@ -437,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));
|
||||
@@ -467,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(
|
||||
@@ -485,6 +599,7 @@ export const upload = async (
|
||||
await releaser.deleteReleaseAsset({
|
||||
owner,
|
||||
repo,
|
||||
release_id: releaseId,
|
||||
asset_id: latestAsset.id,
|
||||
});
|
||||
return await handleUploadedAsset(await uploadAsset());
|
||||
@@ -517,23 +632,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 (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}`,
|
||||
);
|
||||
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,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}`,
|
||||
);
|
||||
@@ -719,14 +850,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 +867,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 (getErrorStatus(error) !== 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 +947,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 +988,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 +1008,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 +1090,13 @@ async function createRelease(
|
||||
release: canonicalRelease,
|
||||
created: canonicalRelease.id === createdRelease.data.id,
|
||||
};
|
||||
} catch (error) {
|
||||
} catch (error: unknown) {
|
||||
const errorStatus = getErrorStatus(error);
|
||||
// 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: ${errorStatus}`);
|
||||
console.log(errorMessage(error));
|
||||
|
||||
switch (error.status) {
|
||||
switch (errorStatus) {
|
||||
case 403:
|
||||
console.log(
|
||||
'Skip retry — your GitHub token/PAT does not have the required permission to create a release',
|
||||
@@ -946,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 = error.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...',
|
||||
);
|
||||
@@ -969,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')
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
+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) {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"useUnknownInCatchVariables": false,
|
||||
"useUnknownInCatchVariables": true,
|
||||
/* Basic Options */
|
||||
// "incremental": true, /* Enable incremental compilation */
|
||||
"target": "es2022",
|
||||
|
||||
+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: 94,
|
||||
branches: 90,
|
||||
functions: 95,
|
||||
lines: 94,
|
||||
},
|
||||
},
|
||||
include: ['__tests__/**/*.ts'],
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user