Compare commits

...

32 Commits
v2 ... v3

Author SHA1 Message Date
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
fenley f345337888 fix: publish existing draft releases as prereleases (#801)
* fix: respect input_draft when updating existing release

* fix: reuse draft releases before publishing

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

* fix: bound and canonicalize draft discovery

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

* fix: preserve unowned draft releases

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

* fix: recheck duplicate drafts before cleanup

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

---------

Signed-off-by: Rui Chen <rui@chenrui.dev>
Co-authored-by: Rui Chen <rui@chenrui.dev>
2026-07-13 01:59:29 -04:00
Rui Chen d8a89a2066 fix: upload small checksum assets reliably (#815)
Signed-off-by: Rui Chen <rui@chenrui.dev>
2026-07-12 23:26:33 -04:00
Rui Chen 45ece40c31 chore(deps): remove unused TypeScript tooling (#814)
Signed-off-by: Rui Chen <rui@chenrui.dev>
2026-07-12 22:07:52 -04:00
Rui Chen f6b913c3f9 feat: improve release error reporting and test coverage (#813)
Signed-off-by: Rui Chen <rui@chenrui.dev>
2026-07-12 21:52:34 -04:00
Rui Chen 15f193d7d8 chore(deps): upgrade TypeScript to 7 (#812)
Signed-off-by: Rui Chen <rui@chenrui.dev>
2026-07-12 21:26:48 -04:00
dependabot[bot] cc8268d46a chore(deps): bump actions/checkout in the github-actions group (#810)
Bumps the github-actions group with 1 update: [actions/checkout](https://github.com/actions/checkout).


Updates `actions/checkout` from 6.0.3 to 7.0.0
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/df4cb1c069e1874edd31b4311f1884172cec0e10...9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: 7.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-12 20:22:42 -04:00
dependabot[bot] fd0ed1e85b chore(deps): bump the npm group with 3 updates (#811)
Bumps the npm group with 3 updates: [@vitest/coverage-v8](https://github.com/vitest-dev/vitest/tree/HEAD/packages/coverage-v8), [prettier](https://github.com/prettier/prettier) and [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest).


Updates `@vitest/coverage-v8` from 4.1.8 to 4.1.9
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.9/packages/coverage-v8)

Updates `prettier` from 3.8.4 to 3.9.0
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/3.8.4...3.9.0)

Updates `vitest` from 4.1.8 to 4.1.9
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.9/packages/vitest)

---
updated-dependencies:
- dependency-name: "@vitest/coverage-v8"
  dependency-version: 4.1.9
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm
- dependency-name: prettier
  dependency-version: 3.9.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm
- dependency-name: vitest
  dependency-version: 4.1.9
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-12 20:22:35 -04:00
Rui Chen 132dbbba49 chore: bump node 24 pin to 24.18.0
Signed-off-by: Rui Chen <rui@chenrui.dev>
2026-06-25 12:27:27 -04:00
dependabot[bot] 3743f4e7d1 chore(deps): bump undici from 6.24.1 to 6.27.0 (#807)
* chore(deps): bump undici from 6.24.1 to 6.27.0

Bumps [undici](https://github.com/nodejs/undici) from 6.24.1 to 6.27.0.
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v6.24.1...v6.27.0)

---
updated-dependencies:
- dependency-name: undici
  dependency-version: 6.27.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

* chore(deps): rebuild dist for undici 6.27.0

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

---------

Signed-off-by: dependabot[bot] <support@github.com>
Signed-off-by: Rui Chen <rui@chenrui.dev>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Rui Chen <rui@chenrui.dev>
2026-06-25 12:25:02 -04:00
dependabot[bot] 566ec910ab chore(deps): bump the npm group with 2 updates (#808)
Bumps the npm group with 2 updates: [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) and [prettier](https://github.com/prettier/prettier).


Updates `@types/node` from 24.13.1 to 24.13.2
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `prettier` from 3.8.3 to 3.8.4
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/3.8.3...3.8.4)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 24.13.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm
- dependency-name: prettier
  dependency-version: 3.8.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-25 12:13:00 -04:00
Rui Chen 718ea10b13 release 3.0.1
Signed-off-by: Rui Chen <rui@chenrui.dev>
2026-06-19 10:41:38 -04:00
dependabot[bot] f1a938b9d8 chore(deps): bump esbuild from 0.28.0 to 0.28.1 (#802)
* chore(deps): bump esbuild from 0.28.0 to 0.28.1

Bumps [esbuild](https://github.com/evanw/esbuild) from 0.28.0 to 0.28.1.
- [Release notes](https://github.com/evanw/esbuild/releases)
- [Changelog](https://github.com/evanw/esbuild/blob/main/CHANGELOG.md)
- [Commits](https://github.com/evanw/esbuild/compare/v0.28.0...v0.28.1)

---
updated-dependencies:
- dependency-name: esbuild
  dependency-version: 0.28.1
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>

* chore(deps): rebuild dist for esbuild 0.28.1

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

---------

Signed-off-by: dependabot[bot] <support@github.com>
Signed-off-by: Rui Chen <rui@chenrui.dev>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Rui Chen <rui@chenrui.dev>
2026-06-19 10:37:08 -04:00
dependabot[bot] 0066ead0de chore(deps): bump vite from 8.0.14 to 8.0.16 (#806)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 8.0.14 to 8.0.16.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.0.16/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 8.0.16
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-19 10:19:47 -04:00
dependabot[bot] dc643cac62 chore(deps): bump the npm group with 3 updates (#805)
Bumps the npm group with 3 updates: [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node), [@vitest/coverage-v8](https://github.com/vitest-dev/vitest/tree/HEAD/packages/coverage-v8) and [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest).


Updates `@types/node` from 24.12.4 to 24.13.1
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `@vitest/coverage-v8` from 4.1.7 to 4.1.8
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.8/packages/coverage-v8)

Updates `vitest` from 4.1.7 to 4.1.8
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.8/packages/vitest)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 24.13.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm
- dependency-name: "@vitest/coverage-v8"
  dependency-version: 4.1.8
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm
- dependency-name: vitest
  dependency-version: 4.1.8
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-19 10:19:40 -04:00
dependabot[bot] 85ee99b6b2 chore(deps): bump actions/checkout in the github-actions group (#804)
Bumps the github-actions group with 1 update: [actions/checkout](https://github.com/actions/checkout).


Updates `actions/checkout` from 6.0.2 to 6.0.3
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/de0fac2e4500dabe0009e67214ff5f5447ce83dd...df4cb1c069e1874edd31b4311f1884172cec0e10)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: 6.0.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-19 10:19:30 -04:00
dependabot[bot] 9ed3cf9a68 chore(deps): bump the npm group with 2 updates (#800)
Bumps the npm group with 2 updates: [@vitest/coverage-v8](https://github.com/vitest-dev/vitest/tree/HEAD/packages/coverage-v8) and [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest).


Updates `@vitest/coverage-v8` from 4.1.6 to 4.1.7
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.7/packages/coverage-v8)

Updates `vitest` from 4.1.6 to 4.1.7
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.7/packages/vitest)

---
updated-dependencies:
- dependency-name: "@vitest/coverage-v8"
  dependency-version: 4.1.7
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm
- dependency-name: vitest
  dependency-version: 4.1.7
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-07 16:28:00 -04:00
dependabot[bot] 3efcac8951 chore(deps): bump the npm group with 3 updates (#798)
Bumps the npm group with 3 updates: [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node), [@vitest/coverage-v8](https://github.com/vitest-dev/vitest/tree/HEAD/packages/coverage-v8) and [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest).


Updates `@types/node` from 24.12.3 to 24.12.4
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `@vitest/coverage-v8` from 4.1.5 to 4.1.6
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.6/packages/coverage-v8)

Updates `vitest` from 4.1.5 to 4.1.6
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.6/packages/vitest)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 24.12.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm
- dependency-name: "@vitest/coverage-v8"
  dependency-version: 4.1.6
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm
- dependency-name: vitest
  dependency-version: 4.1.6
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-29 09:38:53 -04:00
dependabot[bot] 05d6b9164a chore(deps): bump brace-expansion from 5.0.5 to 5.0.6 (#797)
Bumps [brace-expansion](https://github.com/juliangruber/brace-expansion) from 5.0.5 to 5.0.6.
- [Release notes](https://github.com/juliangruber/brace-expansion/releases)
- [Commits](https://github.com/juliangruber/brace-expansion/compare/v5.0.5...v5.0.6)

---
updated-dependencies:
- dependency-name: brace-expansion
  dependency-version: 5.0.6
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-29 09:38:47 -04:00
dependabot[bot] 403a5240f3 chore(deps): bump @types/node from 24.12.2 to 24.12.3 in the npm group (#796)
Bumps the npm group with 1 update: [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node).


Updates `@types/node` from 24.12.2 to 24.12.3
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 24.12.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-17 16:56:11 -04:00
dependabot[bot] 437e073e78 chore(deps): bump the npm group with 4 updates (#792)
Bumps the npm group with 4 updates: [@actions/core](https://github.com/actions/toolkit/tree/HEAD/packages/core), [@actions/github](https://github.com/actions/toolkit/tree/HEAD/packages/github), [@vitest/coverage-v8](https://github.com/vitest-dev/vitest/tree/HEAD/packages/coverage-v8) and [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest).


Updates `@actions/core` from 3.0.0 to 3.0.1
- [Changelog](https://github.com/actions/toolkit/blob/main/packages/core/RELEASES.md)
- [Commits](https://github.com/actions/toolkit/commits/HEAD/packages/core)

Updates `@actions/github` from 9.1.0 to 9.1.1
- [Changelog](https://github.com/actions/toolkit/blob/main/packages/github/RELEASES.md)
- [Commits](https://github.com/actions/toolkit/commits/HEAD/packages/github)

Updates `@vitest/coverage-v8` from 4.1.4 to 4.1.5
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.5/packages/coverage-v8)

Updates `vitest` from 4.1.4 to 4.1.5
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.5/packages/vitest)

---
updated-dependencies:
- dependency-name: "@actions/core"
  dependency-version: 3.0.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm
- dependency-name: "@actions/github"
  dependency-version: 9.1.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm
- dependency-name: "@vitest/coverage-v8"
  dependency-version: 4.1.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm
- dependency-name: vitest
  dependency-version: 4.1.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-05 22:09:35 -04:00
dependabot[bot] 5a1e17dcea chore(deps): bump actions/setup-node in the github-actions group (#791)
Bumps the github-actions group with 1 update: [actions/setup-node](https://github.com/actions/setup-node).


Updates `actions/setup-node` from 6.3.0 to 6.4.0
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/53b83947a5a98c8d113130e565377fae1a50d02f...48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: 6.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-05 22:09:28 -04:00
dependabot[bot] 2bc819c87a chore(deps): bump postcss from 8.5.9 to 8.5.10 (#789)
Bumps [postcss](https://github.com/postcss/postcss) from 8.5.9 to 8.5.10.
- [Release notes](https://github.com/postcss/postcss/releases)
- [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/postcss/compare/8.5.9...8.5.10)

---
updated-dependencies:
- dependency-name: postcss
  dependency-version: 8.5.10
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-26 01:22:12 -04:00
dependabot[bot] c90b8762f6 chore(deps): bump the npm group with 2 updates (#788)
Bumps the npm group with 2 updates: [prettier](https://github.com/prettier/prettier) and [typescript](https://github.com/microsoft/TypeScript).


Updates `prettier` from 3.8.2 to 3.8.3
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/3.8.2...3.8.3)

Updates `typescript` from 6.0.2 to 6.0.3
- [Release notes](https://github.com/microsoft/TypeScript/releases)
- [Commits](https://github.com/microsoft/TypeScript/compare/v6.0.2...v6.0.3)

---
updated-dependencies:
- dependency-name: prettier
  dependency-version: 3.8.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm
- dependency-name: typescript
  dependency-version: 6.0.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-25 22:38:20 -04:00
Rui Chen 4dd30955cc chore: remove .github/FUNDING.yml
Signed-off-by: Rui Chen <rui@chenrui.dev>
2026-04-12 11:52:07 -04:00
Rui Chen be9cff7d8e feat: add dependabot cooldown, update pinact annotations (#785)
Signed-off-by: Rui Chen <rui@chenrui.dev>
2026-04-12 11:31:15 -04:00
Rui Chen 07399f3317 docs: clarify issue links in release summaries
closes #778

Signed-off-by: Rui Chen <rui@chenrui.dev>
2026-04-12 08:16:23 -04:00
dependabot[bot] 93e4a0038b chore(deps): keep @types/node on the Node 24 line (#784)
* chore(deps): bump @types/node from 24.12.2 to 25.6.0 in the npm group

Bumps the npm group with 1 update: [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node).


Updates `@types/node` from 24.12.2 to 25.6.0
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 25.6.0
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: npm
...

Signed-off-by: dependabot[bot] <support@github.com>

* chore(deps): keep @types/node on the node24 line

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

---------

Signed-off-by: dependabot[bot] <support@github.com>
Signed-off-by: Rui Chen <rui@chenrui.dev>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Rui Chen <rui@chenrui.dev>
2026-04-12 00:57:20 -04:00
Copilot b430933298 release: cut v3.0.0 for Node 24 upgrade (#670)
- feat: move action runtime to node24 and require Node >=24
- chore: bump @types/node, enable Dependabot updates, rebuild dist
- cut 3.0.0 release

Signed-off-by: Rui Chen <rui@chenrui.dev>
2026-04-12 00:45:48 -04:00
dependabot[bot] c2e35e05a7 chore(deps): bump the npm group across 1 directory with 7 updates (#783)
* chore(deps): bump the npm group across 1 directory with 7 updates

Bumps the npm group with 6 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [@actions/github](https://github.com/actions/toolkit/tree/HEAD/packages/github) | `9.0.0` | `9.1.0` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `20.19.37` | `20.19.39` |
| [@vitest/coverage-v8](https://github.com/vitest-dev/vitest/tree/HEAD/packages/coverage-v8) | `4.1.0` | `4.1.4` |
| [esbuild](https://github.com/evanw/esbuild) | `0.27.4` | `0.28.0` |
| [prettier](https://github.com/prettier/prettier) | `3.8.1` | `3.8.2` |
| [typescript](https://github.com/microsoft/TypeScript) | `5.9.3` | `6.0.2` |



Updates `@actions/github` from 9.0.0 to 9.1.0
- [Changelog](https://github.com/actions/toolkit/blob/main/packages/github/RELEASES.md)
- [Commits](https://github.com/actions/toolkit/commits/HEAD/packages/github)

Updates `@types/node` from 20.19.37 to 20.19.39
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `@vitest/coverage-v8` from 4.1.0 to 4.1.4
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.4/packages/coverage-v8)

Updates `esbuild` from 0.27.4 to 0.28.0
- [Release notes](https://github.com/evanw/esbuild/releases)
- [Changelog](https://github.com/evanw/esbuild/blob/main/CHANGELOG.md)
- [Commits](https://github.com/evanw/esbuild/compare/v0.27.4...v0.28.0)

Updates `prettier` from 3.8.1 to 3.8.2
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/3.8.1...3.8.2)

Updates `typescript` from 5.9.3 to 6.0.2
- [Release notes](https://github.com/microsoft/TypeScript/releases)
- [Commits](https://github.com/microsoft/TypeScript/compare/v5.9.3...v6.0.2)

Updates `vitest` from 4.1.0 to 4.1.4
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.4/packages/vitest)

---
updated-dependencies:
- dependency-name: "@actions/github"
  dependency-version: 9.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm
- dependency-name: "@types/node"
  dependency-version: 20.19.39
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm
- dependency-name: "@vitest/coverage-v8"
  dependency-version: 4.1.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm
- dependency-name: esbuild
  dependency-version: 0.28.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm
- dependency-name: prettier
  dependency-version: 3.8.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm
- dependency-name: typescript
  dependency-version: 6.0.2
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: npm
- dependency-name: vitest
  dependency-version: 4.1.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm
...

Signed-off-by: dependabot[bot] <support@github.com>

* ci: comment on stale dist bundles

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

* update dist/index.js

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

---------

Signed-off-by: dependabot[bot] <support@github.com>
Signed-off-by: Rui Chen <rui@chenrui.dev>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Rui Chen <rui@chenrui.dev>
2026-04-11 23:55:00 -04:00
23 changed files with 2850 additions and 835 deletions
-1
View File
@@ -1 +0,0 @@
ko_fi: softprops
+5 -1
View File
@@ -14,7 +14,9 @@ updates:
- ">=3.0.0"
- dependency-name: "@types/node"
versions:
- ">=22.0.0"
- ">=25.0.0"
cooldown:
default-days: 7
commit-message:
prefix: "chore(deps)"
- package-ecosystem: github-actions
@@ -25,5 +27,7 @@ updates:
github-actions:
patterns:
- "*"
cooldown:
default-days: 7
commit-message:
prefix: "chore(deps)"
+48 -2
View File
@@ -4,26 +4,72 @@ on:
push:
pull_request:
permissions:
contents: read
issues: write
pull-requests: write
jobs:
build:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: ".tool-versions"
cache: "npm"
- name: Install
run: npm ci
- name: Typecheck
run: npm run typecheck
- name: Build
run: npm run build
- name: Check dist freshness
id: dist_freshness
continue-on-error: true
run: |
git diff --exit-code --stat -- dist/index.js \
|| (echo "##[error] found changed dist/index.js after build. please run 'npm run build' and commit the updated bundle" \
&& exit 1)
- name: Comment on stale dist bundle
if: github.event_name == 'pull_request' && steps.dist_freshness.outcome == 'failure'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
marker='<!-- dist-freshness-check -->'
diff_stat="$(git diff --stat -- dist/index.js)"
body=$(cat <<EOF
$marker
\`dist/index.js\` changed after \`npm run build\`.
Please run \`npm run build\` and commit the regenerated bundle.
\`\`\`text
$diff_stat
\`\`\`
EOF
)
existing_id="$(
gh api "repos/$GH_REPO/issues/$PR_NUMBER/comments" \
--jq '.[] | select(.user.login == "github-actions[bot]" and (.body | contains("<!-- dist-freshness-check -->"))) | .id' \
| tail -n 1
)"
if [ -n "$existing_id" ]; then
gh api --method PATCH "repos/$GH_REPO/issues/comments/$existing_id" -f body="$body" >/dev/null
else
gh api --method POST "repos/$GH_REPO/issues/$PR_NUMBER/comments" -f body="$body" >/dev/null
fi
- name: Fail on stale dist bundle
if: steps.dist_freshness.outcome == 'failure'
run: |
echo "##[error] found changed dist/index.js after build. please run 'npm run build' and commit the updated bundle"
exit 1
- name: Test
run: npm run test
- name: Format
+1 -1
View File
@@ -1 +1 @@
nodejs 24.14.1
nodejs 24.18.0
+1 -1
View File
@@ -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.
+51
View File
@@ -1,3 +1,54 @@
## 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
## 3.0.0
`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`.
## What's Changed
### Other Changes 🔄
* 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
## 2.6.2
## What's Changed
+10 -7
View File
@@ -39,6 +39,9 @@ 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`.
Below is a simple example of `step.if` tag gating
```yaml
@@ -53,7 +56,7 @@ jobs:
- name: Checkout
uses: actions/checkout@v6
- name: Release
uses: softprops/action-gh-release@v2
uses: softprops/action-gh-release@v3
if: github.ref_type == 'tag'
```
@@ -74,7 +77,7 @@ jobs:
- name: Checkout
uses: actions/checkout@v6
- name: Release
uses: softprops/action-gh-release@v2
uses: softprops/action-gh-release@v3
```
### ⬆️ Uploading release assets
@@ -105,7 +108,7 @@ jobs:
- name: Test
run: cat Release.txt
- name: Release
uses: softprops/action-gh-release@v2
uses: softprops/action-gh-release@v3
if: github.ref_type == 'tag'
with:
files: Release.txt
@@ -129,7 +132,7 @@ jobs:
- name: Test
run: cat Release.txt
- name: Release
uses: softprops/action-gh-release@v2
uses: softprops/action-gh-release@v3
if: github.ref_type == 'tag'
with:
files: |
@@ -146,7 +149,7 @@ and keep the `files` patterns relative to that directory.
```yaml
- name: Release
uses: softprops/action-gh-release@v2
uses: softprops/action-gh-release@v3
if: github.ref_type == 'tag'
with:
working_directory: dist
@@ -175,7 +178,7 @@ jobs:
- name: Generate Changelog
run: echo "# Good things have arrived" > ${{ github.workspace }}-CHANGELOG.txt
- name: Release
uses: softprops/action-gh-release@v2
uses: softprops/action-gh-release@v3
if: github.ref_type == 'tag'
with:
body_path: ${{ github.workspace }}-CHANGELOG.txt
@@ -193,7 +196,7 @@ comparison range does not match the release series you want to publish.
```yaml
- name: Release
uses: softprops/action-gh-release@v2
uses: softprops/action-gh-release@v3
with:
tag_name: stage-2026-03-15
target_commitish: ${{ github.sha }}
+6 -4
View File
@@ -13,6 +13,7 @@ Use this checklist when cutting a new `action-gh-release` release.
1. Update [package.json](package.json) to the new version.
2. Add the new entry at the top of [CHANGELOG.md](CHANGELOG.md).
- Summarize the release in 1 short paragraph.
- If the summary mentions issues, use plain `#123` references or full issue links; do not wrap issue numbers like `#123` in backticks.
- Prefer user-facing fixes and features over internal churn.
- Keep the merged PR list aligned with `.github/release.yml` categories.
3. Run `npm i` to refresh [package-lock.json](package-lock.json).
@@ -22,13 +23,14 @@ 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 2.5.4`.
- Use a plain release commit message like `release 3.0.0`.
6. Create the annotated tag for the release commit.
- Example: `git tag -a v2.5.4 -m "v2.5.4"`
- 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 v2.5.4`
- Example: `git push origin master && git push origin v3.0.0`
8. Move the floating major tag to the new release tag.
- For the current major line, either run `npm run updatetag` or update the script first if the major ever changes.
- 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.
- Prefer the release body from [CHANGELOG.md](CHANGELOG.md), then let GitHub append generated notes only if they add value.
+644 -27
View File
@@ -3,6 +3,7 @@ import {
findTagFromReleases,
finalizeRelease,
GitHubReleaser,
listReleaseAssets,
mimeOrDefault,
release,
Release,
@@ -13,9 +14,33 @@ import {
import { mkdtempSync, rmSync, writeFileSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { assert, describe, expect, it, vi } from 'vitest';
import { afterEach, assert, describe, expect, it, vi } from 'vitest';
const unexpected = (method: string) =>
vi.fn().mockRejectedValue(new Error(`Unexpected ${method} call`));
const createReleaser = (overrides: Partial<Releaser> = {}): Releaser => ({
getReleaseByTag: unexpected('getReleaseByTag'),
createRelease: unexpected('createRelease'),
updateRelease: unexpected('updateRelease'),
finalizeRelease: unexpected('finalizeRelease'),
allReleases: async function* () {
throw new Error('Unexpected allReleases call');
},
listReleaseAssets: unexpected('listReleaseAssets'),
deleteReleaseAsset: unexpected('deleteReleaseAsset'),
deleteRelease: unexpected('deleteRelease'),
updateReleaseAsset: unexpected('updateReleaseAsset'),
uploadReleaseAsset: unexpected('uploadReleaseAsset'),
...overrides,
});
describe('github', () => {
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
const config = {
github_token: 'test-token',
github_ref: 'refs/tags/v1.0.0',
@@ -105,6 +130,92 @@ describe('github', () => {
assert.deepStrictEqual(result, targetRelease);
});
it('falls back to paginated releases when the direct lookup cannot see a draft', async () => {
const draftRelease = { ...mockRelease, draft: true };
const pageAfterMatch = vi.fn();
const releaser = {
...mockReleaser,
allReleases: async function* () {
yield { data: [{ ...mockRelease, tag_name: 'other' }] };
yield { data: [draftRelease] };
pageAfterMatch();
yield { data: [] };
},
};
const result = await findTagFromReleases(releaser, owner, repo, draftRelease.tag_name);
assert.strictEqual(result, draftRelease);
expect(pageAfterMatch).not.toHaveBeenCalled();
});
it('does not exhaust pagination while checking a brand-new tag', async () => {
let pagesRead = 0;
const releaser = {
...mockReleaser,
allReleases: async function* () {
pagesRead += 1;
yield { data: [] };
pagesRead += 1;
yield { data: [] };
throw new Error('Only the first 10000 results are available');
},
};
const result = await findTagFromReleases(releaser, owner, repo, 'brand-new-tag');
expect(result).toBeUndefined();
expect(pagesRead).toBe(2);
});
it('selects the canonical draft without deleting a pre-existing duplicate', async () => {
const canonicalDraft: Release = {
...mockRelease,
id: 1,
draft: true,
assets: [{ id: 99, name: 'existing.zip' }],
};
const duplicateDraft: Release = {
...canonicalDraft,
id: 2,
name: 'manually authored draft',
body: 'notes that must not be deleted',
assets: [],
};
const deleteRelease = vi.fn().mockResolvedValue(undefined);
const releaser = {
...mockReleaser,
allReleases: async function* () {
yield { data: [duplicateDraft, canonicalDraft] };
},
deleteRelease,
};
const result = await findTagFromReleases(releaser, owner, repo, canonicalDraft.tag_name);
expect(result).toBe(canonicalDraft);
expect(deleteRelease).not.toHaveBeenCalled();
});
it('retries draft discovery when GitHub release listing is briefly stale', async () => {
vi.useFakeTimers();
const draftRelease = { ...mockRelease, draft: true };
let listingAttempt = 0;
const releaser = {
...mockReleaser,
allReleases: async function* () {
listingAttempt += 1;
yield { data: listingAttempt === 1 ? [] : [draftRelease] };
},
};
const resultPromise = findTagFromReleases(releaser, owner, repo, draftRelease.tag_name, 2);
await vi.advanceTimersByTimeAsync(1000);
await expect(resultPromise).resolves.toBe(draftRelease);
expect(listingAttempt).toBe(2);
});
it('returns undefined when release is not found (404)', async () => {
const releaser = {
...mockReleaser,
@@ -130,6 +241,18 @@ describe('github', () => {
}
});
it('re-throws errors from the draft listing fallback', async () => {
const listingError = new Error('release listing failed');
const releaser = {
...mockReleaser,
allReleases: async function* () {
throw listingError;
},
};
await expect(findTagFromReleases(releaser, owner, repo, 'v1.0.0')).rejects.toBe(listingError);
});
it('finds a release with empty tag name', async () => {
const emptyTag = '';
const targetRelease = {
@@ -148,7 +271,158 @@ describe('github', () => {
});
});
describe('listReleaseAssets', () => {
const releaseToInspect: Release = {
id: 42,
upload_url: 'https://uploads.example.test/releases/42/assets',
html_url: 'https://example.test/releases/42',
tag_name: 'v1.0.0',
name: 'v1.0.0',
target_commitish: 'main',
draft: false,
prerelease: false,
assets: [],
};
it('returns assets with the repository and release parameters', async () => {
const assets = [{ id: 7, name: 'action.zip' }];
const listReleaseAssetsSpy = vi.fn().mockResolvedValue(assets);
const releaser = createReleaser({ listReleaseAssets: listReleaseAssetsSpy });
await expect(listReleaseAssets(config, releaser, releaseToInspect)).resolves.toBe(assets);
expect(listReleaseAssetsSpy).toHaveBeenCalledOnce();
expect(listReleaseAssetsSpy).toHaveBeenCalledWith({
owner: 'owner',
repo: 'repo',
release_id: 42,
});
});
it('retries a transient failure and then succeeds', async () => {
const assets = [{ id: 7, name: 'action.zip' }];
const listReleaseAssetsSpy = vi
.fn()
.mockRejectedValueOnce(new Error('temporary failure'))
.mockResolvedValueOnce(assets);
const releaser = createReleaser({ listReleaseAssets: listReleaseAssetsSpy });
await expect(listReleaseAssets(config, releaser, releaseToInspect, 2)).resolves.toBe(assets);
expect(listReleaseAssetsSpy).toHaveBeenCalledTimes(2);
});
it('stops after exhausting the configured attempts', async () => {
const listReleaseAssetsSpy = vi.fn().mockRejectedValue(new Error('persistent failure'));
const releaser = createReleaser({ listReleaseAssets: listReleaseAssetsSpy });
await expect(listReleaseAssets(config, releaser, releaseToInspect, 2)).rejects.toThrow(
'Too many retries.',
);
expect(listReleaseAssetsSpy).toHaveBeenCalledTimes(2);
});
});
describe('GitHubReleaser', () => {
it('uses GitHub standard asset deletion without adding the release ID', async () => {
const deleteReleaseAsset = vi.fn().mockResolvedValue(undefined);
const request = vi.fn();
const releaser = new GitHubReleaser({
rest: { repos: { deleteReleaseAsset } },
request,
} as any);
await releaser.deleteReleaseAsset({
owner: 'owner',
repo: 'repo',
release_id: 42,
asset_id: 99,
});
expect(deleteReleaseAsset).toHaveBeenCalledOnce();
expect(deleteReleaseAsset).toHaveBeenCalledWith({
owner: 'owner',
repo: 'repo',
asset_id: 99,
});
expect(request).not.toHaveBeenCalled();
});
it('falls back to release-scoped asset deletion after a standard 404', async () => {
const deleteReleaseAsset = vi.fn().mockRejectedValue({
status: 404,
message: 'standard route not found',
});
const request = vi.fn().mockResolvedValue({ status: 204 });
const releaser = new GitHubReleaser({
rest: { repos: { deleteReleaseAsset } },
request,
} as any);
await releaser.deleteReleaseAsset({
owner: 'owner',
repo: 'repo',
release_id: 42,
asset_id: 99,
});
expect(request).toHaveBeenCalledOnce();
expect(request).toHaveBeenCalledWith(
'DELETE /repos/{owner}/{repo}/releases/{release_id}/assets/{asset_id}',
{
owner: 'owner',
repo: 'repo',
release_id: 42,
asset_id: 99,
},
);
});
it('does not mask non-404 asset deletion failures', async () => {
const deletionError = { status: 403, message: 'forbidden' };
const request = vi.fn();
const releaser = new GitHubReleaser({
rest: {
repos: { deleteReleaseAsset: vi.fn().mockRejectedValue(deletionError) },
},
request,
} as any);
await expect(
releaser.deleteReleaseAsset({
owner: 'owner',
repo: 'repo',
release_id: 42,
asset_id: 99,
}),
).rejects.toBe(deletionError);
expect(request).not.toHaveBeenCalled();
});
it('reports both failures when release-scoped deletion also fails', async () => {
const releaser = new GitHubReleaser({
rest: {
repos: {
deleteReleaseAsset: vi
.fn()
.mockRejectedValue({ status: 404, message: 'standard route not found' }),
},
},
request: vi
.fn()
.mockRejectedValue({ status: 404, message: 'release-scoped route not found' }),
} as any);
await expect(
releaser.deleteReleaseAsset({
owner: 'owner',
repo: 'repo',
release_id: 42,
asset_id: 99,
}),
).rejects.toThrow(
'Failed to delete release asset 99. GitHub endpoint: standard route not found; release-scoped endpoint: release-scoped route not found',
);
});
it('passes previous_tag_name to generateReleaseNotes and strips it from createRelease', async () => {
const generateReleaseNotes = vi.fn(async () => ({
data: {
@@ -519,6 +793,130 @@ describe('github', () => {
});
describe('error handling', () => {
it.each([
{
name: 'a remote repository without a discussion category',
repository: 'remote-owner/release-repo',
category: undefined,
},
{
name: 'the current repository without a discussion category',
repository: 'owner/repo',
category: undefined,
},
{
name: 'a repository with a discussion category',
repository: 'owner/repo',
category: 'Announcements',
},
])('classifies a create-release 404 for $name without retrying', async (testCase) => {
const releaseError = {
status: 404,
message: 'Not Found - create-a-release',
};
const log = vi.spyOn(console, 'log').mockImplementation(() => undefined);
const createRelease = vi.fn().mockRejectedValue(releaseError);
const releaser = createReleaser({
getReleaseByTag: vi.fn().mockRejectedValue({ status: 404 }),
createRelease,
allReleases: async function* () {
yield { data: [] };
},
});
const thrown = await release(
{
...config,
github_repository: testCase.repository,
input_discussion_category_name: testCase.category,
},
releaser,
1,
).catch((error) => error);
expect(thrown).toMatchObject({
name: 'ReleaseCreationError',
status: 404,
cause: releaseError,
});
expect(thrown.message).toContain(
`GitHub returned 404 while creating the release. Verify that ${testCase.repository} exists under the expected owner`,
);
expect(thrown.message).toContain('the token can access it');
expect(thrown.message).toContain('fine-grained PAT');
expect(thrown.message).toContain('Contents: write');
expect(thrown.message).toContain('GitHub response: Not Found - create-a-release');
if (testCase.category) {
expect(thrown.message).toContain('Discussions and the requested category "Announcements"');
} else {
expect(thrown.message).not.toContain('discussion category mismatch');
expect(thrown.message).not.toContain('requested category');
}
expect(createRelease).toHaveBeenCalledOnce();
expect(log).not.toHaveBeenCalledWith(
expect.stringContaining('Unexpected error fetching GitHub release'),
);
});
it('classifies a draft-listing 404 as a repository access failure', async () => {
const listingError = {
status: 404,
message: 'Not Found - list-releases',
};
const createRelease = vi.fn();
const releaser = createReleaser({
getReleaseByTag: vi.fn().mockRejectedValue({ status: 404 }),
allReleases: async function* () {
throw listingError;
},
createRelease,
});
const thrown = await release(
{
...config,
github_repository: 'remote-owner/release-repo',
input_discussion_category_name: undefined,
},
releaser,
1,
).catch((error) => error);
expect(thrown).toMatchObject({
name: 'ReleaseAccessError',
status: 404,
cause: listingError,
});
expect(thrown.message).toContain('GitHub returned 404 while checking existing releases');
expect(thrown.message).toContain('remote-owner/release-repo');
expect(thrown.message).toContain('the token can access it');
expect(thrown.message).toContain('fine-grained PAT');
expect(thrown.message).toContain('Contents: write');
expect(thrown.message).toContain('GitHub response: Not Found - list-releases');
expect(thrown.message).not.toContain('discussion category mismatch');
expect(createRelease).not.toHaveBeenCalled();
});
it('reports a useful create error without assuming response data exists', async () => {
const releaseError = {
status: 403,
message: 'Resource not accessible by integration',
};
const log = vi.spyOn(console, 'log').mockImplementation(() => undefined);
const releaser = createReleaser({
getReleaseByTag: vi.fn().mockRejectedValue({ status: 404 }),
createRelease: vi.fn().mockRejectedValue(releaseError),
allReleases: async function* () {
yield { data: [] };
},
});
await expect(release(config, releaser, 1)).rejects.toBe(releaseError);
expect(log).toHaveBeenCalledWith('⚠️ GitHub release failed with status: 403');
expect(log).toHaveBeenCalledWith('Resource not accessible by integration');
expect(log).not.toHaveBeenCalledWith('undefined');
});
it('passes previous_tag_name through when creating a release with generated notes', async () => {
const createReleaseSpy = vi.fn(async () => ({
data: {
@@ -633,14 +1031,18 @@ describe('github', () => {
assets: [],
};
const createReleaseSpy = vi.fn(async () => ({ data: createdRelease }));
let created = false;
const createReleaseSpy = vi.fn(async () => {
created = true;
return { data: createdRelease };
});
const mockReleaser: Releaser = {
getReleaseByTag: () => Promise.reject({ status: 404 }),
createRelease: createReleaseSpy,
updateRelease: () => Promise.reject('Not implemented'),
finalizeRelease: () => Promise.reject('Not implemented'),
allReleases: async function* () {
yield { data: [createdRelease] };
yield { data: created ? [createdRelease] : [] };
},
listReleaseAssets: () => Promise.reject('Not implemented'),
deleteReleaseAsset: () => Promise.reject('Not implemented'),
@@ -661,6 +1063,59 @@ describe('github', () => {
);
});
it.each([
['an omitted draft input', undefined],
['a null-expression draft input', undefined],
['an explicit false draft input', false],
['an explicit true draft input', true],
])('reuses an existing draft for %s', async (_name, input_draft) => {
const existingDraft: Release = {
id: 17,
upload_url: 'draft-upload',
html_url: 'draft-html',
tag_name: 'v1.0.0',
name: 'draft release',
body: 'draft body',
target_commitish: 'main',
draft: true,
prerelease: false,
assets: [],
};
const updatedDraft = { ...existingDraft, prerelease: true };
const updateRelease = vi.fn().mockResolvedValue({ data: updatedDraft });
const createRelease = unexpected('createRelease');
const deleteRelease = unexpected('deleteRelease');
const releaser = createReleaser({
getReleaseByTag: vi.fn().mockRejectedValue({ status: 404 }),
allReleases: async function* () {
yield { data: [existingDraft] };
},
createRelease,
updateRelease,
deleteRelease,
});
const result = await release(
{
...config,
input_draft,
input_prerelease: true,
},
releaser,
);
expect(result).toEqual({ release: updatedDraft, created: false });
expect(updateRelease).toHaveBeenCalledWith(
expect.objectContaining({
release_id: existingDraft.id,
draft: true,
prerelease: true,
}),
);
expect(createRelease).not.toHaveBeenCalled();
expect(deleteRelease).not.toHaveBeenCalled();
});
it('retries upload after deleting conflicting asset on 422 already_exists race', async () => {
const uploadReleaseAsset = vi
.fn()
@@ -695,6 +1150,7 @@ describe('github', () => {
'https://uploads.github.com/repos/owner/repo/releases/1/assets',
'__tests__/release.txt',
[],
1,
);
expect(result).toStrictEqual({ id: 123, name: 'release.txt' });
@@ -706,11 +1162,52 @@ describe('github', () => {
expect(deleteReleaseAsset).toHaveBeenCalledWith({
owner: 'owner',
repo: 'repo',
release_id: 1,
asset_id: 99,
});
expect(uploadReleaseAsset).toHaveBeenCalledTimes(2);
});
it('does not upload until deletion of an existing asset succeeds', async () => {
const deletionError = new Error('asset deletion failed');
const uploadReleaseAsset = vi.fn();
const releaser = createReleaser({
deleteReleaseAsset: vi.fn().mockRejectedValue(deletionError),
uploadReleaseAsset,
});
await expect(
upload(
config,
releaser,
'https://uploads.github.com/repos/owner/repo/releases/1/assets',
'__tests__/release.txt',
[{ id: 99, name: 'release.txt' }],
1,
),
).rejects.toBe(deletionError);
expect(uploadReleaseAsset).not.toHaveBeenCalled();
});
it('skips deletion and upload when overwrite_files is false', async () => {
const deleteReleaseAsset = vi.fn();
const uploadReleaseAsset = vi.fn();
const releaser = createReleaser({ deleteReleaseAsset, uploadReleaseAsset });
await expect(
upload(
{ ...config, input_overwrite_files: false },
releaser,
'https://uploads.github.com/repos/owner/repo/releases/1/assets',
'__tests__/release.txt',
[{ id: 99, name: 'release.txt' }],
1,
),
).resolves.toBeNull();
expect(deleteReleaseAsset).not.toHaveBeenCalled();
expect(uploadReleaseAsset).not.toHaveBeenCalled();
});
it('surfaces an actionable immutable-release error for prerelease uploads', async () => {
const tempDir = mkdtempSync(join(tmpdir(), 'gh-release-immutable-'));
const assetPath = join(tempDir, 'draft-false.txt');
@@ -750,6 +1247,7 @@ describe('github', () => {
'https://uploads.github.com/repos/owner/repo/releases/1/assets',
assetPath,
[],
1,
),
).rejects.toThrow(
'Cannot upload asset draft-false.txt to an immutable release. GitHub only allows asset uploads before a release is published, but draft prereleases publish with the release.published event instead of release.prereleased.',
@@ -804,6 +1302,7 @@ describe('github', () => {
'https://uploads.github.com/repos/owner/repo/releases/1/assets',
dotfilePath,
[],
1,
);
expect(result).toStrictEqual({ id: 123, name: 'default.config', label: '.config' });
@@ -815,6 +1314,7 @@ describe('github', () => {
expect(deleteReleaseAsset).toHaveBeenCalledWith({
owner: 'owner',
repo: 'repo',
release_id: 1,
asset_id: 99,
});
expect(updateReleaseAsset).toHaveBeenCalledWith({
@@ -831,6 +1331,7 @@ describe('github', () => {
});
it('handles 422 already_exists error gracefully', async () => {
vi.useFakeTimers();
const existingRelease = {
id: 1,
upload_url: 'test',
@@ -877,7 +1378,7 @@ describe('github', () => {
}),
finalizeRelease: () => Promise.reject('Not implemented'),
allReleases: async function* () {
yield { data: [existingRelease] };
yield { data: createAttempts > 0 ? [existingRelease] : [] };
},
listReleaseAssets: () => Promise.reject('Not implemented'),
deleteReleaseAsset: () => Promise.reject('Not implemented'),
@@ -885,7 +1386,9 @@ describe('github', () => {
uploadReleaseAsset: () => Promise.reject('Not implemented'),
} as const;
const result = await release(config, mockReleaser, 2);
const resultPromise = release(config, mockReleaser, 2);
await vi.advanceTimersByTimeAsync(1000);
const result = await resultPromise;
assert.ok(result);
assert.equal(result.release.id, 1);
assert.equal(result.created, false);
@@ -979,11 +1482,14 @@ describe('github', () => {
}
return Promise.resolve({ data: canonicalRelease });
},
createRelease: () => Promise.resolve({ data: duplicateRelease }),
createRelease: () => {
lookupCount += 1;
return Promise.resolve({ data: duplicateRelease });
},
updateRelease: () => Promise.reject('Not implemented'),
finalizeRelease: () => Promise.reject('Not implemented'),
allReleases: async function* () {
yield { data: [duplicateRelease, canonicalRelease] };
yield { data: lookupCount > 1 ? [duplicateRelease, canonicalRelease] : [] };
},
listReleaseAssets: () => Promise.reject('Not implemented'),
deleteReleaseAsset: () => Promise.reject('Not implemented'),
@@ -992,7 +1498,7 @@ describe('github', () => {
uploadReleaseAsset: () => Promise.reject('Not implemented'),
};
const result = await release(config, mockReleaser, 2);
const result = await release(config, mockReleaser, 1);
assert.equal(result.release.id, canonicalRelease.id);
assert.equal(result.created, false);
@@ -1003,6 +1509,47 @@ describe('github', () => {
});
});
it('does not delete a pre-existing draft while canonicalizing its own create', async () => {
const createdRelease: Release = {
id: 1,
upload_url: 'created-upload',
html_url: 'created-html',
tag_name: 'v1.0.0',
name: 'created release',
body: 'created body',
target_commitish: 'main',
draft: true,
prerelease: false,
assets: [],
};
const manualDraft: Release = {
...createdRelease,
id: 2,
name: 'manually authored draft',
body: 'notes that must not be deleted',
};
let created = false;
const deleteRelease = vi.fn().mockResolvedValue(undefined);
const releaser = createReleaser({
getReleaseByTag: vi.fn(() =>
created ? Promise.resolve({ data: createdRelease }) : Promise.reject({ status: 404 }),
),
createRelease: vi.fn(async () => {
created = true;
return { data: createdRelease };
}),
allReleases: async function* () {
yield { data: created ? [createdRelease, manualDraft] : [] };
},
deleteRelease,
});
const result = await release(config, releaser, 1);
expect(result).toEqual({ release: createdRelease, created: true });
expect(deleteRelease).not.toHaveBeenCalled();
});
it('falls back to recent releases when tag lookup still lags after create', async () => {
const canonicalRelease: Release = {
id: 1,
@@ -1029,14 +1576,18 @@ describe('github', () => {
assets: [],
};
let created = false;
const deleteReleaseSpy = vi.fn(async () => undefined);
const mockReleaser: Releaser = {
getReleaseByTag: () => Promise.reject({ status: 404 }),
createRelease: () => Promise.resolve({ data: duplicateRelease }),
createRelease: () => {
created = true;
return Promise.resolve({ data: duplicateRelease });
},
updateRelease: () => Promise.reject('Not implemented'),
finalizeRelease: () => Promise.reject('Not implemented'),
allReleases: async function* () {
yield { data: [duplicateRelease, canonicalRelease] };
yield { data: created ? [duplicateRelease, canonicalRelease] : [] };
},
listReleaseAssets: () => Promise.reject('Not implemented'),
deleteReleaseAsset: () => Promise.reject('Not implemented'),
@@ -1056,7 +1607,59 @@ describe('github', () => {
});
});
it('deletes the just-created duplicate draft even if recent release listing misses it', async () => {
it.each([
['gains assets', { assets: [{ id: 9, name: 'concurrent.zip' }] }],
['is published', { draft: false }],
])('does not delete its created release when it %s concurrently', async (_name, patch) => {
const canonicalRelease: Release = {
id: 1,
upload_url: 'canonical-upload',
html_url: 'canonical-html',
tag_name: 'v1.0.0',
name: 'canonical',
body: 'test',
target_commitish: 'main',
draft: true,
prerelease: false,
assets: [],
};
const createdRelease: Release = {
...canonicalRelease,
id: 2,
name: 'created duplicate',
};
const refreshedCreatedRelease: Release = { ...createdRelease, ...patch };
const releaseByTag = refreshedCreatedRelease.draft
? canonicalRelease
: refreshedCreatedRelease;
let created = false;
const deleteRelease = vi.fn().mockResolvedValue(undefined);
const releaser = createReleaser({
getReleaseByTag: vi.fn(() =>
created ? Promise.resolve({ data: releaseByTag }) : Promise.reject({ status: 404 }),
),
createRelease: vi.fn(async () => {
created = true;
return { data: createdRelease };
}),
allReleases: async function* () {
yield {
data: created ? [refreshedCreatedRelease, canonicalRelease] : [],
};
},
deleteRelease,
});
const result = await release(config, releaser, 1);
expect(result).toEqual({
release: releaseByTag,
created: releaseByTag.id === createdRelease.id,
});
expect(deleteRelease).not.toHaveBeenCalled();
});
it('does not delete the just-created duplicate when refreshed state is unavailable', async () => {
const canonicalRelease: Release = {
id: 1,
upload_url: 'canonical-upload',
@@ -1092,11 +1695,14 @@ describe('github', () => {
}
return Promise.resolve({ data: canonicalRelease });
},
createRelease: () => Promise.resolve({ data: duplicateRelease }),
createRelease: () => {
lookupCount += 1;
return Promise.resolve({ data: duplicateRelease });
},
updateRelease: () => Promise.reject('Not implemented'),
finalizeRelease: () => Promise.reject('Not implemented'),
allReleases: async function* () {
yield { data: [canonicalRelease] };
yield { data: lookupCount > 1 ? [canonicalRelease] : [] };
},
listReleaseAssets: () => Promise.reject('Not implemented'),
deleteReleaseAsset: () => Promise.reject('Not implemented'),
@@ -1105,15 +1711,11 @@ describe('github', () => {
uploadReleaseAsset: () => Promise.reject('Not implemented'),
};
const result = await release(config, mockReleaser, 2);
const result = await release(config, mockReleaser, 1);
assert.equal(result.release.id, canonicalRelease.id);
assert.equal(result.created, false);
expect(deleteReleaseSpy).toHaveBeenCalledWith({
owner: 'owner',
repo: 'repo',
release_id: duplicateRelease.id,
});
expect(deleteReleaseSpy).not.toHaveBeenCalled();
});
});
@@ -1160,6 +1762,7 @@ describe('github', () => {
'https://uploads.example.test/assets',
dotfilePath,
[],
1,
);
expect(updateReleaseAssetSpy).toHaveBeenCalledWith({
@@ -1231,6 +1834,7 @@ describe('github', () => {
'https://uploads.github.com/repos/owner/repo/releases/1/assets',
dotfilePath,
[],
1,
);
expect(updateReleaseAssetSpy).toHaveBeenNthCalledWith(1, {
@@ -1301,6 +1905,7 @@ describe('github', () => {
'https://uploads.github.com/repos/owner/repo/releases/1/assets',
dotfilePath,
[],
1,
);
expect(listReleaseAssetsSpy).toHaveBeenCalledWith({
@@ -1360,6 +1965,7 @@ describe('github', () => {
'https://uploads.github.com/repos/owner/repo/releases/1/assets',
dotfilePath,
[],
1,
);
expect(listReleaseAssetsSpy).toHaveBeenCalledWith({
@@ -1378,6 +1984,7 @@ describe('github', () => {
});
it('polls for a matching asset after update-a-release-asset 404 before failing', async () => {
vi.useFakeTimers();
const tempDir = mkdtempSync(join(tmpdir(), 'gh-release-dotfile-'));
const dotfilePath = join(tempDir, '.config');
writeFileSync(dotfilePath, 'config');
@@ -1419,9 +2026,11 @@ describe('github', () => {
'https://uploads.github.com/repos/owner/repo/releases/1/assets',
dotfilePath,
[],
1,
);
await new Promise((resolve) => setTimeout(resolve, 1100));
await vi.waitFor(() => expect(listReleaseAssetsSpy).toHaveBeenCalledTimes(1));
await vi.advanceTimersByTimeAsync(1000);
const result = await resultPromise;
@@ -1473,18 +2082,26 @@ describe('github', () => {
};
try {
await upload(config, releaser, 'https://uploads.example.test/assets', dotfilePath, [
{
id: 1,
name: 'default.config',
label: '.config',
},
]);
await upload(
config,
releaser,
'https://uploads.example.test/assets',
dotfilePath,
[
{
id: 1,
name: 'default.config',
label: '.config',
},
],
1,
);
expect(deleteReleaseAssetSpy).toHaveBeenCalledWith({
asset_id: 1,
owner: 'owner',
repo: 'repo',
release_id: 1,
});
} finally {
rmSync(tempDir, { recursive: true, force: true });
+11
View File
@@ -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();
});
+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');
}
}
});
});
+385
View File
@@ -0,0 +1,385 @@
import type { Config } from '../src/util';
import type { Release } from '../src/github';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => ({
setFailed: vi.fn(),
setOutput: vi.fn(),
getOctokit: vi.fn(),
GitHubReleaser: vi.fn(function () {
return mocks.releaser;
}),
releaser: { name: 'releaser' },
release: vi.fn(),
finalizeRelease: vi.fn(),
upload: vi.fn(),
listReleaseAssets: vi.fn(),
parseConfig: vi.fn(),
isTag: vi.fn(),
paths: vi.fn(),
unmatchedPatterns: vi.fn(),
uploadUrl: vi.fn(),
}));
vi.mock('@actions/core', () => ({
setFailed: mocks.setFailed,
setOutput: mocks.setOutput,
}));
vi.mock('@actions/github', () => ({ getOctokit: mocks.getOctokit }));
vi.mock('../src/github', () => ({
GitHubReleaser: mocks.GitHubReleaser,
release: mocks.release,
finalizeRelease: mocks.finalizeRelease,
upload: mocks.upload,
listReleaseAssets: mocks.listReleaseAssets,
}));
vi.mock('../src/util', async (importOriginal) => {
const actual = await importOriginal<typeof import('../src/util')>();
return {
...actual,
parseConfig: mocks.parseConfig,
isTag: mocks.isTag,
paths: mocks.paths,
unmatchedPatterns: mocks.unmatchedPatterns,
uploadUrl: mocks.uploadUrl,
};
});
import { run } from '../src/run';
const baseConfig: Config = {
github_token: 'token',
github_ref: 'refs/tags/v1.0.0',
github_repository: 'owner/repo',
input_files: [],
input_draft: false,
input_prerelease: false,
input_preserve_order: false,
input_overwrite_files: true,
input_fail_on_unmatched_files: false,
input_generate_release_notes: false,
input_append_body: false,
input_make_latest: undefined,
};
const initialRelease: Release = {
id: 41,
upload_url: 'https://uploads.example.test/releases/41/assets{?name,label}',
html_url: 'https://example.test/releases/41',
tag_name: 'v1.0.0',
name: 'v1.0.0',
target_commitish: 'main',
draft: true,
prerelease: false,
assets: [{ id: 1, name: 'existing.zip' }],
};
const finalizedRelease: Release = {
...initialRelease,
id: 42,
upload_url: 'https://uploads.example.test/releases/42/assets{?name,label}',
html_url: 'https://example.test/releases/42',
draft: false,
};
const deferred = <T>() => {
let resolve!: (value: T) => void;
const promise = new Promise<T>((resolvePromise) => {
resolve = resolvePromise;
});
return { promise, resolve };
};
describe('run', () => {
let config: Config;
beforeEach(() => {
vi.clearAllMocks();
config = { ...baseConfig };
mocks.parseConfig.mockImplementation(() => config);
mocks.isTag.mockImplementation((ref: string) => ref.startsWith('refs/tags/'));
mocks.unmatchedPatterns.mockReturnValue([]);
mocks.paths.mockReturnValue([]);
mocks.uploadUrl.mockImplementation((url: string) => url.split('{')[0]);
mocks.getOctokit.mockReturnValue({ name: 'octokit' });
mocks.release.mockResolvedValue({ release: initialRelease, created: true });
mocks.finalizeRelease.mockResolvedValue(finalizedRelease);
mocks.upload.mockResolvedValue(undefined);
mocks.listReleaseAssets.mockResolvedValue([]);
vi.spyOn(console, 'log').mockImplementation(() => undefined);
vi.spyOn(console, 'warn').mockImplementation(() => undefined);
});
afterEach(() => {
vi.restoreAllMocks();
});
it.each([
['an explicit tag', { github_ref: 'refs/heads/main', input_tag_name: 'v1.0.0' }],
['a tag ref', { github_ref: 'refs/tags/v1.0.0', input_tag_name: undefined }],
[
'a draft without a tag',
{ github_ref: 'refs/heads/main', input_tag_name: undefined, input_draft: true },
],
])('accepts %s', async (_name, patch) => {
config = { ...config, ...patch };
await run();
expect(mocks.release).toHaveBeenCalledOnce();
expect(mocks.setFailed).not.toHaveBeenCalled();
});
it('reports the documented tag requirement for a non-tag ref', async () => {
config = { ...config, github_ref: 'refs/heads/main', input_tag_name: undefined };
await run();
expect(mocks.setFailed).toHaveBeenCalledWith('⚠️ GitHub Releases requires a tag');
expect(mocks.release).not.toHaveBeenCalled();
});
it('warns with each unmatched input pattern when strict matching is disabled', async () => {
config = { ...config, input_files: ['dist/*.zip'] };
mocks.unmatchedPatterns.mockReturnValue(['dist/*.zip']);
await run();
expect(console.warn).toHaveBeenCalledWith("🤔 Pattern 'dist/*.zip' does not match any files.");
expect(mocks.setFailed).not.toHaveBeenCalled();
});
it('fails with the unmatched input pattern when strict matching is enabled', async () => {
config = {
...config,
input_files: ['dist/*.zip'],
input_fail_on_unmatched_files: true,
};
mocks.unmatchedPatterns.mockReturnValue(['dist/*.zip']);
await run();
expect(mocks.setFailed).toHaveBeenCalledWith(
"⚠️ Pattern 'dist/*.zip' does not match any files.",
);
expect(mocks.release).not.toHaveBeenCalled();
});
it.each([
[false, 'warns', '🤔 dist/*.zip does not include a valid file.'],
[true, 'fails', '⚠️ dist/*.zip does not include a valid file.'],
])(
'%s strict matching %s when resolved patterns contain no files',
async (strict, _verb, message) => {
config = {
...config,
input_files: ['dist/*.zip'],
input_fail_on_unmatched_files: strict,
};
await run();
if (strict) {
expect(mocks.setFailed).toHaveBeenCalledWith(message);
expect(mocks.finalizeRelease).not.toHaveBeenCalled();
} else {
expect(console.warn).toHaveBeenCalledWith(message);
expect(mocks.finalizeRelease).toHaveBeenCalledOnce();
}
},
);
it('forwards valid files and the working directory through upload boundaries', async () => {
config = {
...config,
input_files: ['dist/*.zip'],
input_working_directory: 'fixture',
};
mocks.paths.mockReturnValue(['fixture/dist/action.zip']);
mocks.upload.mockResolvedValue({ id: 7 });
mocks.listReleaseAssets.mockResolvedValue([{ id: 7, name: 'action.zip' }]);
await run();
expect(mocks.paths).toHaveBeenCalledWith(['dist/*.zip'], 'fixture');
expect(mocks.upload).toHaveBeenCalledWith(
config,
mocks.releaser,
'https://uploads.example.test/releases/41/assets',
'fixture/dist/action.zip',
initialRelease.assets,
initialRelease.id,
);
});
it('starts uploads concurrently by default', async () => {
config = { ...config, input_files: ['one.zip', 'two.zip'] };
mocks.paths.mockReturnValue(['one.zip', 'two.zip']);
const first = deferred<{ id: number }>();
const second = deferred<{ id: number }>();
mocks.upload.mockImplementation((_config, _releaser, _url, path) =>
path === 'one.zip' ? first.promise : second.promise,
);
const result = run();
await Promise.resolve();
expect(mocks.upload).toHaveBeenCalledTimes(2);
first.resolve({ id: 1 });
second.resolve({ id: 2 });
await result;
});
it('waits for each upload when preserve_order is enabled', async () => {
config = {
...config,
input_files: ['one.zip', 'two.zip'],
input_preserve_order: true,
};
mocks.paths.mockReturnValue(['one.zip', 'two.zip']);
const first = deferred<{ id: number }>();
const second = deferred<{ id: number }>();
mocks.upload.mockImplementation((_config, _releaser, _url, path) =>
path === 'one.zip' ? first.promise : second.promise,
);
const result = run();
await Promise.resolve();
expect(mocks.upload).toHaveBeenCalledTimes(1);
first.resolve({ id: 1 });
await vi.waitFor(() => expect(mocks.upload).toHaveBeenCalledTimes(2));
second.resolve({ id: 2 });
await result;
});
it('keeps an existing draft unpublished until its upload completes', async () => {
config = {
...config,
input_draft: false,
input_prerelease: true,
input_files: ['asset.zip'],
};
mocks.release.mockResolvedValue({ release: initialRelease, created: false });
mocks.paths.mockReturnValue(['asset.zip']);
const pendingUpload = deferred<{ id: number }>();
mocks.upload.mockReturnValue(pendingUpload.promise);
const result = run();
await Promise.resolve();
expect(mocks.upload).toHaveBeenCalledOnce();
expect(mocks.finalizeRelease).not.toHaveBeenCalled();
pendingUpload.resolve({ id: 7 });
await result;
expect(mocks.finalizeRelease).toHaveBeenCalledWith(
config,
mocks.releaser,
initialRelease,
false,
);
});
it('leaves an existing draft recoverable when an upload fails', async () => {
config = {
...config,
input_draft: false,
input_prerelease: true,
input_files: ['asset.zip'],
};
mocks.release.mockResolvedValue({ release: initialRelease, created: false });
mocks.paths.mockReturnValue(['asset.zip']);
mocks.upload.mockRejectedValue(new Error('upload failed'));
await run();
expect(mocks.finalizeRelease).not.toHaveBeenCalled();
expect(mocks.setFailed).toHaveBeenCalledWith('upload failed');
});
it('finalizes after uploads and outputs only newly uploaded assets without uploader data', async () => {
config = { ...config, input_files: ['one.zip', 'skipped.zip', 'two.zip'] };
mocks.paths.mockReturnValue(['one.zip', 'skipped.zip', 'two.zip']);
mocks.upload
.mockResolvedValueOnce({ id: 7 })
.mockResolvedValueOnce(null)
.mockResolvedValueOnce({ id: 9 });
mocks.listReleaseAssets.mockResolvedValue([
{ id: 1, name: 'existing.zip', uploader: { login: 'someone' } },
{ id: 7, name: 'one.zip', size: 10, uploader: { login: 'bot' } },
{ id: 9, name: 'two.zip', label: 'Two', uploader: null },
]);
await run();
expect(mocks.release.mock.invocationCallOrder[0]).toBeLessThan(
mocks.upload.mock.invocationCallOrder[0],
);
expect(mocks.upload.mock.invocationCallOrder[2]).toBeLessThan(
mocks.finalizeRelease.mock.invocationCallOrder[0],
);
expect(mocks.finalizeRelease).toHaveBeenCalledWith(
config,
mocks.releaser,
initialRelease,
true,
);
expect(mocks.listReleaseAssets).toHaveBeenCalledWith(config, mocks.releaser, finalizedRelease);
expect(mocks.setOutput).toHaveBeenCalledWith('assets', [
{ id: 7, name: 'one.zip', size: 10 },
{ id: 9, name: 'two.zip', label: 'Two' },
]);
expect(mocks.setOutput).toHaveBeenCalledWith('url', finalizedRelease.html_url);
expect(mocks.setOutput).toHaveBeenCalledWith('id', '42');
expect(mocks.setOutput).toHaveBeenCalledWith('upload_url', finalizedRelease.upload_url);
});
it('outputs an empty asset list and skips refresh when no upload returns an ID', async () => {
config = { ...config, input_files: ['skipped.zip'] };
mocks.paths.mockReturnValue(['skipped.zip']);
mocks.upload.mockResolvedValue(undefined);
await run();
expect(mocks.listReleaseAssets).not.toHaveBeenCalled();
expect(mocks.setOutput).toHaveBeenCalledWith('assets', []);
});
it('configures rate-limit and abuse callbacks without making live requests', async () => {
await run();
const options = mocks.getOctokit.mock.calls[0][1];
const request = { method: 'GET', url: '/repos/owner/repo', request: { retryCount: 0 } };
expect(options.throttle.onRateLimit(5, request)).toBe(true);
expect(
options.throttle.onRateLimit(5, { ...request, request: { retryCount: 1 } }),
).toBeUndefined();
expect(options.throttle.onAbuseLimit(10, request)).toBeUndefined();
expect(console.warn).toHaveBeenCalledWith(
'Request quota exhausted for request GET /repos/owner/repo',
);
expect(console.warn).toHaveBeenCalledWith('Abuse detected for request GET /repos/owner/repo');
});
it.each([
[new Error('release failed'), 'release failed'],
['release failed', 'release failed'],
[{ message: 'release failed' }, 'release failed'],
[null, 'Unknown error'],
[undefined, 'Unknown error'],
])('normalizes a thrown value before reporting failure', async (thrown, expected) => {
mocks.parseConfig.mockImplementation(() => {
throw thrown;
});
await run();
expect(mocks.setFailed).toHaveBeenCalledWith(expected);
});
});
+394
View File
@@ -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);
}
});
});
+25
View File
@@ -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({
+1 -1
View File
@@ -75,7 +75,7 @@ outputs:
assets:
description: "JSON array containing information about each uploaded asset, in the format given [here](https://docs.github.com/en/rest/reference/repos#upload-a-release-asset--code-samples) (minus the `uploader` field)"
runs:
using: "node20"
using: "node24"
main: "dist/index.js"
branding:
color: "green"
+31 -31
View File
File diff suppressed because one or more lines are too long
+717 -568
View File
File diff suppressed because it is too large Load Diff
+14 -14
View File
@@ -1,17 +1,17 @@
{
"name": "action-gh-release",
"version": "2.6.2",
"version": "3.0.2",
"private": true,
"description": "GitHub Action for creating GitHub Releases",
"main": "lib/main.js",
"scripts": {
"build": "esbuild src/main.ts --bundle --platform=node --format=cjs --target=node20 --outfile=dist/index.js --minify",
"build-debug": "esbuild src/main.ts --bundle --platform=node --format=cjs --target=node20 --outfile=dist/index.js --sourcemap --keep-names",
"build": "esbuild src/main.ts --bundle --platform=node --format=cjs --target=node24 --outfile=dist/index.js --minify",
"build-debug": "esbuild src/main.ts --bundle --platform=node --format=cjs --target=node24 --outfile=dist/index.js --sourcemap --keep-names",
"typecheck": "tsc --noEmit",
"test": "vitest --coverage",
"fmt": "prettier --write \"src/**/*.ts\" \"__tests__/**/*.ts\"",
"fmtcheck": "prettier --check \"src/**/*.ts\" \"__tests__/**/*.ts\"",
"updatetag": "git tag -d v2 && git push origin :v2 && git tag -a v2 -m '' && git push origin v2"
"updatetag": "git tag -d v3 >/dev/null 2>&1 || true; git push origin :v3 >/dev/null 2>&1 || true; git tag -a v3 -m '' && git push origin v3"
},
"repository": {
"type": "git",
@@ -21,24 +21,24 @@
"actions"
],
"author": "softprops",
"engines": {
"node": ">=24"
},
"dependencies": {
"@actions/core": "^3.0.0",
"@actions/github": "^9.0.0",
"@actions/core": "^3.0.1",
"@actions/github": "^9.1.1",
"@octokit/plugin-retry": "^8.1.0",
"@octokit/plugin-throttling": "^11.0.3",
"glob": "^13.0.6",
"mime-types": "^3.0.2"
},
"devDependencies": {
"@types/glob": "^9.0.0",
"@types/mime-types": "^3.0.1",
"@types/node": "^20.19.37",
"@vitest/coverage-v8": "^4.1.0",
"esbuild": "^0.27.3",
"prettier": "3.8.1",
"ts-node": "^10.9.2",
"typescript": "^5.9.3",
"typescript-formatter": "^7.2.2",
"@types/node": "^24",
"@vitest/coverage-v8": "^4.1.9",
"esbuild": "^0.28.1",
"prettier": "3.9.0",
"typescript": "^7.0.2",
"vitest": "^4.0.4"
}
}
+192 -63
View File
@@ -1,12 +1,26 @@
import { GitHub } from '@actions/github/lib/utils';
import { statSync } from 'fs';
import { open } from 'fs/promises';
import { open, type FileHandle } from 'fs/promises';
import { lookup } from 'mime-types';
import { basename } from 'path';
import { alignAssetName, Config, isTag, normalizeTagName, releaseBody } from './util';
import { alignAssetName, Config, errorMessage, isTag, normalizeTagName, releaseBody } from './util';
type GitHub = InstanceType<typeof GitHub>;
type UploadChunk = ArrayBuffer | Uint8Array<ArrayBufferLike>;
type UploadBody = ReadableStream<Uint8Array<ArrayBufferLike>>;
const fileUploadStream = (fileHandle: FileHandle): UploadBody => {
const source = fileHandle.readableWebStream() as ReadableStream<UploadChunk>;
return source.pipeThrough(
new TransformStream<UploadChunk, Uint8Array<ArrayBufferLike>>({
transform(chunk, controller) {
controller.enqueue(chunk instanceof Uint8Array ? chunk : new Uint8Array(chunk));
},
}),
);
};
export interface ReleaseAsset {
name: string;
mime: string;
@@ -54,6 +68,43 @@ type ReleaseMutationParams = {
previous_tag_name?: string;
};
class ReleaseCreationError extends Error {
readonly status = 404;
constructor(message: string, cause: unknown) {
super(message, { cause });
this.name = 'ReleaseCreationError';
}
}
class ReleaseAccessError extends Error {
readonly status = 404;
constructor(message: string, cause: unknown) {
super(message, { cause });
this.name = 'ReleaseAccessError';
}
}
const repositoryAccessGuidance = (owner: string, repo: string): string =>
`Verify that ${owner}/${repo} exists under the expected owner, the token can access it, the repository is selected when using a fine-grained PAT, and the token has Contents: write permission.`;
const releaseCreation404Message = (
owner: string,
repo: string,
discussionCategory: string | undefined,
error: unknown,
): string => {
const discussionGuidance = discussionCategory
? ` Also verify that Discussions and the requested category "${discussionCategory}" are enabled.`
: '';
return `GitHub returned 404 while creating the release. ${repositoryAccessGuidance(owner, repo)}${discussionGuidance} GitHub response: ${errorMessage(error)}`;
};
const releaseLookup404Message = (owner: string, repo: string, error: unknown): string =>
`GitHub returned 404 while checking existing releases. ${repositoryAccessGuidance(owner, repo)} GitHub response: ${errorMessage(error)}`;
export interface Releaser {
getReleaseByTag(params: { owner: string; repo: string; tag: string }): Promise<{ data: Release }>;
@@ -82,7 +133,12 @@ export interface Releaser {
release_id: number;
}): Promise<Array<{ id: number; name: string; label?: string | null; [key: string]: any }>>;
deleteReleaseAsset(params: { owner: string; repo: string; asset_id: number }): Promise<void>;
deleteReleaseAsset(params: {
owner: string;
repo: string;
release_id: number;
asset_id: number;
}): Promise<void>;
deleteRelease(params: { owner: string; repo: string; release_id: number }): Promise<void>;
@@ -99,7 +155,7 @@ export interface Releaser {
size: number;
mime: string;
token: string;
data: any;
data: UploadBody;
}): Promise<{ status: number; data: any }>;
}
@@ -215,9 +271,34 @@ export class GitHubReleaser implements Releaser {
async deleteReleaseAsset(params: {
owner: string;
repo: string;
release_id: number;
asset_id: number;
}): Promise<void> {
await this.github.rest.repos.deleteReleaseAsset(params);
const { release_id, ...githubParams } = params;
try {
await this.github.rest.repos.deleteReleaseAsset(githubParams);
} catch (error: unknown) {
const status =
(error as { status?: number; response?: { status?: number } })?.status ??
(error as { response?: { status?: number } })?.response?.status;
if (status !== 404) {
throw error;
}
try {
await this.github.request(
'DELETE /repos/{owner}/{repo}/releases/{release_id}/assets/{asset_id}',
params,
);
} catch (fallbackError: unknown) {
throw new AggregateError(
[error, fallbackError],
`Failed to delete release asset ${params.asset_id}. GitHub endpoint: ${errorMessage(
error,
)}; release-scoped endpoint: ${errorMessage(fallbackError)}`,
);
}
}
}
async deleteRelease(params: { owner: string; repo: string; release_id: number }): Promise<void> {
@@ -239,7 +320,7 @@ export class GitHubReleaser implements Releaser {
size: number;
mime: string;
token: string;
data: any;
data: UploadBody;
}): Promise<{ status: number; data: any }> {
return this.github.request({
method: 'POST',
@@ -307,11 +388,10 @@ export const upload = async (
url: string,
path: string,
currentAssets: Array<{ id: number; name: string; label?: string | null }>,
releaseId: number,
): Promise<any> => {
const [owner, repo] = config.github_repository.split('/');
const { name, mime, size } = asset(path);
const releaseIdMatch = url.match(/\/releases\/(\d+)\/assets/);
const releaseId = releaseIdMatch ? Number(releaseIdMatch[1]) : undefined;
const currentAsset = currentAssets.find(
// GitHub can rewrite uploaded asset names, so compare against both the raw name
// GitHub returns and the restored label we set when available.
@@ -327,6 +407,7 @@ export const upload = async (
asset_id: currentAsset.id || 1,
owner,
repo,
release_id: releaseId,
});
}
}
@@ -367,7 +448,7 @@ export const upload = async (
size,
mime,
token: config.github_token,
data: fh.readableWebStream({ type: 'bytes' }),
data: fileUploadStream(fh),
});
} finally {
await fh.close();
@@ -485,6 +566,7 @@ export const upload = async (
await releaser.deleteReleaseAsset({
owner,
repo,
release_id: releaseId,
asset_id: latestAsset.id,
});
return await handleUploadedAsset(await uploadAsset());
@@ -517,23 +599,36 @@ export const release = async (
if (generate_release_notes && previous_tag_name) {
console.log(`📝 Generating release notes using previous tag ${previous_tag_name}`);
}
let _release: Release | undefined;
try {
const _release: Release | undefined = await findTagFromReleases(releaser, owner, repo, tag);
if (_release === undefined) {
return await createRelease(
tag,
config,
releaser,
owner,
repo,
discussion_category_name,
generate_release_notes,
maxRetries,
previous_tag_name,
);
_release = await findTagFromReleases(releaser, owner, repo, tag, maxRetries);
} catch (error) {
if (error.status === 404) {
const diagnostic = releaseLookup404Message(owner, repo, error);
console.log(`⚠️ ${diagnostic}`);
throw new ReleaseAccessError(diagnostic, error);
}
console.log(
`⚠️ Unexpected error fetching GitHub release for tag ${config.github_ref}: ${error}`,
);
throw error;
}
if (_release === undefined) {
return await createRelease(
tag,
config,
releaser,
owner,
repo,
discussion_category_name,
generate_release_notes,
maxRetries,
previous_tag_name,
);
}
try {
let existingRelease: Release = _release!;
console.log(`Found release ${existingRelease.name} (with id=${existingRelease.id})`);
@@ -591,6 +686,9 @@ export const release = async (
created: false,
};
} catch (error) {
if (error instanceof ReleaseCreationError) {
throw error;
}
if (error.status !== 404) {
console.log(
`⚠️ Unexpected error fetching GitHub release for tag ${config.github_ref}: ${error}`,
@@ -719,14 +817,16 @@ export const listReleaseAssets = async (
/**
* Finds a release by tag name.
*
* Uses the direct getReleaseByTag API for O(1) lookup instead of iterating
* through all releases. This also avoids GitHub's API pagination limit of
* 10000 results which would cause failures for repositories with many releases.
* Uses the direct getReleaseByTag API for O(1) lookup. Because GitHub does not
* expose draft releases through that endpoint, a 404 falls back to a bounded
* scan of recent releases and briefly retries in case the listing is not yet
* consistent.
*
* @param releaser - The GitHub API wrapper for release operations
* @param owner - The owner of the repository
* @param repo - The name of the repository
* @param tag - The tag name to search for
* @param listingAttempts - The maximum number of listing attempts after a direct 404
* @returns The release with the given tag name, or undefined if no release with that tag name is found
*/
export async function findTagFromReleases(
@@ -734,18 +834,35 @@ export async function findTagFromReleases(
owner: string,
repo: string,
tag: string,
listingAttempts: number = 1,
): Promise<Release | undefined> {
try {
const { data: release } = await releaser.getReleaseByTag({ owner, repo, tag });
return release;
} catch (error) {
// Release not found (404) or other error - return undefined to allow creation
if (error.status === 404) {
return undefined;
if (error.status !== 404) {
throw error;
}
// Re-throw unexpected errors
throw error;
}
if (listingAttempts <= 0) {
return undefined;
}
const attempts = Math.max(1, listingAttempts);
for (let attempt = 0; attempt < attempts; attempt += 1) {
const recentReleases = await recentReleasesByTag(releaser, owner, repo, tag);
const canonicalRelease = pickCanonicalRelease(recentReleases, undefined);
if (canonicalRelease) {
return canonicalRelease;
}
if (attempt < attempts - 1) {
await sleep(CREATED_RELEASE_DISCOVERY_RETRY_DELAY_MS);
}
}
return undefined;
}
const CREATED_RELEASE_DISCOVERY_RETRY_DELAY_MS = 1000;
@@ -797,33 +914,31 @@ function pickCanonicalRelease(
})[0];
}
async function cleanupDuplicateDraftReleases(
async function cleanupCreatedDuplicateDraftRelease(
releaser: Releaser,
owner: string,
repo: string,
tag: string,
canonicalReleaseId: number,
releases: Release[],
createdRelease: Release,
): Promise<void> {
const uniqueReleases = Array.from(
new Map(releases.map((release) => [release.id, release])).values(),
);
if (
createdRelease.id === canonicalReleaseId ||
!createdRelease.draft ||
createdRelease.assets.length > 0
) {
return;
}
for (const duplicate of uniqueReleases) {
if (duplicate.id === canonicalReleaseId || !duplicate.draft || duplicate.assets.length > 0) {
continue;
}
try {
console.log(`🧹 Removing duplicate draft release ${duplicate.id} for tag ${tag}...`);
await releaser.deleteRelease({
owner,
repo,
release_id: duplicate.id,
});
} catch (error) {
console.warn(`error deleting duplicate release ${duplicate.id}: ${error}`);
}
try {
console.log(`🧹 Removing duplicate draft release ${createdRelease.id} for tag ${tag}...`);
await releaser.deleteRelease({
owner,
repo,
release_id: createdRelease.id,
});
} catch (error) {
console.warn(`error deleting duplicate release ${createdRelease.id}: ${error}`);
}
}
@@ -840,7 +955,7 @@ async function canonicalizeCreatedRelease(
for (let attempt = 1; attempt <= attempts; attempt += 1) {
let releaseByTag: Release | undefined;
try {
releaseByTag = await findTagFromReleases(releaser, owner, repo, tag);
releaseByTag = await findTagFromReleases(releaser, owner, repo, tag, 0);
} catch (error) {
console.warn(`error reloading release for tag ${tag}: ${error}`);
}
@@ -860,10 +975,19 @@ async function canonicalizeCreatedRelease(
);
}
await cleanupDuplicateDraftReleases(releaser, owner, repo, tag, canonicalRelease.id, [
createdRelease,
...recentReleases,
]);
const refreshedCreatedRelease = recentReleases.find(
(release) => release.id === createdRelease.id,
);
if (refreshedCreatedRelease) {
await cleanupCreatedDuplicateDraftRelease(
releaser,
owner,
repo,
tag,
canonicalRelease.id,
refreshedCreatedRelease,
);
}
return canonicalRelease;
}
@@ -933,12 +1057,16 @@ async function createRelease(
release: canonicalRelease,
created: canonicalRelease.id === createdRelease.data.id,
};
} catch (error) {
} catch (error: unknown) {
const githubError = error as {
status?: number;
response?: { data?: { errors?: Array<{ code?: string }> } };
};
// presume a race with competing matrix runs
console.log(`⚠️ GitHub release failed with status: ${error.status}`);
console.log(`${JSON.stringify(error.response.data)}`);
console.log(`⚠️ GitHub release failed with status: ${githubError.status}`);
console.log(errorMessage(error));
switch (error.status) {
switch (githubError.status) {
case 403:
console.log(
'Skip retry — your GitHub token/PAT does not have the required permission to create a release',
@@ -946,12 +1074,13 @@ async function createRelease(
throw error;
case 404:
console.log('Skip retry - discussion category mismatch');
throw error;
const diagnostic = releaseCreation404Message(owner, repo, discussion_category_name, error);
console.log(`Skip retry — ${diagnostic}`);
throw new ReleaseCreationError(diagnostic, error);
case 422:
// Check if this is a race condition with "already_exists" error
const errorData = error.response?.data;
const errorData = githubError.response?.data;
if (errorData?.errors?.[0]?.code === 'already_exists') {
console.log(
'⚠️ Release already exists (race condition detected), retrying to find and update existing release...',
+2 -113
View File
@@ -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
View File
@@ -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
View File
@@ -28,6 +28,24 @@ export interface Config {
input_make_latest: 'true' | 'false' | 'legacy' | undefined;
}
export const errorMessage = (error: unknown): string => {
if (error instanceof Error) {
return error.message;
}
if (
typeof error === 'object' &&
error !== null &&
'message' in error &&
typeof error.message === 'string'
) {
return error.message;
}
if (error === null || error === undefined) {
return 'Unknown error';
}
return String(error);
};
export const uploadUrl = (url: string): string => {
const templateMarkerPos = url.indexOf('{');
if (templateMarkerPos > -1) {
+8 -1
View File
@@ -4,7 +4,14 @@ export default defineConfig({
test: {
environment: 'node',
coverage: {
reporter: ['text', 'lcov'],
reporter: ['text', 'json-summary', 'lcov'],
include: ['src/**/*.ts'],
thresholds: {
statements: 88,
branches: 83,
functions: 86,
lines: 88,
},
},
include: ['__tests__/**/*.ts'],
},