Compare commits

..

36 Commits

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

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

* test: define nested GitHub status handling

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

---------

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

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

* fix: clarify inaccessible release targets

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

---------

Signed-off-by: Rui Chen <rui@chenrui.dev>
2026-07-13 10:02:44 -04:00
Rui Chen e6c70a53cf fix: replace existing release assets on Gitea (#816)
Signed-off-by: Rui Chen <rui@chenrui.dev>
2026-07-13 08:44:02 -04:00
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
24 changed files with 3490 additions and 898 deletions
-1
View File
@@ -1 +0,0 @@
ko_fi: softprops
+5 -1
View File
@@ -14,7 +14,9 @@ updates:
- ">=3.0.0" - ">=3.0.0"
- dependency-name: "@types/node" - dependency-name: "@types/node"
versions: versions:
- ">=22.0.0" - ">=25.0.0"
cooldown:
default-days: 7
commit-message: commit-message:
prefix: "chore(deps)" prefix: "chore(deps)"
- package-ecosystem: github-actions - package-ecosystem: github-actions
@@ -25,5 +27,7 @@ updates:
github-actions: github-actions:
patterns: patterns:
- "*" - "*"
cooldown:
default-days: 7
commit-message: commit-message:
prefix: "chore(deps)" prefix: "chore(deps)"
+48 -2
View File
@@ -4,26 +4,72 @@ on:
push: push:
pull_request: pull_request:
permissions:
contents: read
issues: write
pull-requests: write
jobs: jobs:
build: build:
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
steps: 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: with:
node-version-file: ".tool-versions" node-version-file: ".tool-versions"
cache: "npm" cache: "npm"
- name: Install - name: Install
run: npm ci run: npm ci
- name: Typecheck
run: npm run typecheck
- name: Build - name: Build
run: npm run build run: npm run build
- name: Check dist freshness - name: Check dist freshness
id: dist_freshness
continue-on-error: true
run: | run: |
git diff --exit-code --stat -- dist/index.js \ 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" \ || (echo "##[error] found changed dist/index.js after build. please run 'npm run build' and commit the updated bundle" \
&& exit 1) && 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 - name: Test
run: npm run test run: npm run test
- name: Format - 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 ## 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/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. - `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. - Keep behavior-specific logic in `src/github.ts` or `src/util.ts`; avoid growing `src/main.ts` with ad-hoc feature branches.
+54
View File
@@ -1,5 +1,59 @@
## 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. `v2.6.2` was the final Node 20-compatible release and is
no longer maintained or supported.
## 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`; freeze `v2` at the final `v2.6.2` release
## 2.6.2 ## 2.6.2
`2.6.2` is the final `v2` release and is no longer maintained or supported. Upgrade
to `v3` for the supported Node 24 runtime and current fixes.
## What's Changed ## What's Changed
### Other Changes 🔄 ### Other Changes 🔄
+13 -7
View File
@@ -39,6 +39,12 @@ 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 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. as it maximizes the reuse value of your workflow for non-tag pushes.
`v2.6.2` is the final `v2` release and is no longer maintained or supported. It
uses the [Node 20 runtime deprecated by GitHub Actions](https://github.blog/changelog/2025-09-19-deprecation-of-node-20-on-github-actions-runners/).
Upgrade to `v3`, which runs on Node 24. If a problem still reproduces on the
latest `v3`, open a new issue with the exact action ref, workflow run URL,
runner, and relevant logs.
Below is a simple example of `step.if` tag gating Below is a simple example of `step.if` tag gating
```yaml ```yaml
@@ -53,7 +59,7 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@v6 uses: actions/checkout@v6
- name: Release - name: Release
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v3
if: github.ref_type == 'tag' if: github.ref_type == 'tag'
``` ```
@@ -74,7 +80,7 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@v6 uses: actions/checkout@v6
- name: Release - name: Release
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v3
``` ```
### ⬆️ Uploading release assets ### ⬆️ Uploading release assets
@@ -105,7 +111,7 @@ jobs:
- name: Test - name: Test
run: cat Release.txt run: cat Release.txt
- name: Release - name: Release
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v3
if: github.ref_type == 'tag' if: github.ref_type == 'tag'
with: with:
files: Release.txt files: Release.txt
@@ -129,7 +135,7 @@ jobs:
- name: Test - name: Test
run: cat Release.txt run: cat Release.txt
- name: Release - name: Release
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v3
if: github.ref_type == 'tag' if: github.ref_type == 'tag'
with: with:
files: | files: |
@@ -146,7 +152,7 @@ and keep the `files` patterns relative to that directory.
```yaml ```yaml
- name: Release - name: Release
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v3
if: github.ref_type == 'tag' if: github.ref_type == 'tag'
with: with:
working_directory: dist working_directory: dist
@@ -175,7 +181,7 @@ jobs:
- name: Generate Changelog - name: Generate Changelog
run: echo "# Good things have arrived" > ${{ github.workspace }}-CHANGELOG.txt run: echo "# Good things have arrived" > ${{ github.workspace }}-CHANGELOG.txt
- name: Release - name: Release
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v3
if: github.ref_type == 'tag' if: github.ref_type == 'tag'
with: with:
body_path: ${{ github.workspace }}-CHANGELOG.txt body_path: ${{ github.workspace }}-CHANGELOG.txt
@@ -193,7 +199,7 @@ comparison range does not match the release series you want to publish.
```yaml ```yaml
- name: Release - name: Release
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v3
with: with:
tag_name: stage-2026-03-15 tag_name: stage-2026-03-15
target_commitish: ${{ github.sha }} target_commitish: ${{ github.sha }}
+25 -10
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. 1. Update [package.json](package.json) to the new version.
2. Add the new entry at the top of [CHANGELOG.md](CHANGELOG.md). 2. Add the new entry at the top of [CHANGELOG.md](CHANGELOG.md).
- Summarize the release in 1 short paragraph. - 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. - Prefer user-facing fixes and features over internal churn.
- Keep the merged PR list aligned with `.github/release.yml` categories. - Keep the merged PR list aligned with `.github/release.yml` categories.
3. Run `npm i` to refresh [package-lock.json](package-lock.json). 3. Run `npm i` to refresh [package-lock.json](package-lock.json).
@@ -22,20 +23,34 @@ Use this checklist when cutting a new `action-gh-release` release.
- `npm run build` - `npm run build`
- `npm test` - `npm test`
5. Commit the release prep. 5. Commit the release prep.
- Use a plain release commit message like `release 2.5.4`. - Use `git commit -s` so the release commit carries a DCO sign-off.
6. Create the annotated tag for the release commit. - Use a plain release commit message like `release X.Y.Z`.
- Example: `git tag -a v2.5.4 -m "v2.5.4"` 6. Push a release branch and open a pull request against `master`.
7. Push the commit and tag. - Wait for required checks and reviews.
- Example: `git push origin master && git push origin v2.5.4` - Do not bypass branch protection or tag an unmerged release branch.
8. Move the floating major tag to the new release tag. 7. After merge, fetch `origin/master` and resolve the exact merged release commit.
- For the current major line, either run `npm run updatetag` or update the script first if the major ever changes. - Confirm that commit contains the expected package version and top changelog entry.
- Verify the floating tag points at the same commit as the new full tag. - When the PR is squash-merged, do not assume the release branch commit is the release commit.
9. Create the GitHub release from the new tag. 8. Create and push the full annotated version tag on the merged release commit.
- Example: `git tag -a vX.Y.Z -m "vX.Y.Z" <release-commit>`
- Push only the full version tag first, then wait for its tag-triggered CI to pass.
9. Move the floating major tag to the same merged release commit.
- For the current major line, run `npm run updatetag` to move `v3`.
- Do not move `v2`; it is frozen at the final, unsupported `v2.6.2` release.
- Verify `v3` and the full version tag are annotated and peel to the same commit.
- Verify `v2` did not move, then wait for the separate `v3` tag-triggered CI run to pass.
10. Create the GitHub release from the full version tag.
- Prefer the release body from [CHANGELOG.md](CHANGELOG.md), then let GitHub append generated notes only if they add value. - Prefer the release body from [CHANGELOG.md](CHANGELOG.md), then let GitHub append generated notes only if they add value.
- Verify the release shows the expected tag, title, notes, and attached artifacts. - Verify the release shows the expected tag, title, notes, draft/prerelease state, and attached artifacts.
11. Run post-release consumer verification in `ruitest2/action-gh-release-test`.
- Run the generic smoke against both the full version tag and `v3`.
- Run regression workflows relevant to the fixes in the release.
- Confirm that every disposable release, tag, discussion, container, volume, and temporary credential was cleaned up.
## Notes ## Notes
- Behavior changes should already have matching updates in [README.md](README.md), [action.yml](action.yml), tests, and `dist/index.js` before release prep begins. - Behavior changes should already have matching updates in [README.md](README.md), [action.yml](action.yml), tests, and `dist/index.js` before release prep begins.
- Docs-only releases still need an intentional changelog entry and version bump decision. - Docs-only releases still need an intentional changelog entry and version bump decision.
- If a release is mainly bug fixes, keep the title and summary patch-oriented; do not bury the actual fixes under dependency noise. - If a release is mainly bug fixes, keep the title and summary patch-oriented; do not bury the actual fixes under dependency noise.
- Do not move the floating major tag or publish the GitHub release until the full
version tag's CI passes.
+1154 -57
View File
File diff suppressed because it is too large Load Diff
+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');
}
}
});
});
+433
View File
@@ -0,0 +1,433 @@
import type { Config } from '../src/util';
import type { Release } from '../src/github';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => ({
setFailed: vi.fn(),
setOutput: vi.fn(),
getOctokit: vi.fn(),
GitHubReleaser: vi.fn(function () {
return mocks.releaser;
}),
releaser: { name: 'releaser' },
release: vi.fn(),
finalizeRelease: vi.fn(),
upload: vi.fn(),
listReleaseAssets: vi.fn(),
parseConfig: vi.fn(),
isTag: vi.fn(),
paths: vi.fn(),
unmatchedPatterns: vi.fn(),
uploadUrl: vi.fn(),
}));
vi.mock('@actions/core', () => ({
setFailed: mocks.setFailed,
setOutput: mocks.setOutput,
}));
vi.mock('@actions/github', () => ({ getOctokit: mocks.getOctokit }));
vi.mock('../src/github', () => ({
GitHubReleaser: mocks.GitHubReleaser,
release: mocks.release,
finalizeRelease: mocks.finalizeRelease,
upload: mocks.upload,
listReleaseAssets: mocks.listReleaseAssets,
}));
vi.mock('../src/util', async (importOriginal) => {
const actual = await importOriginal<typeof import('../src/util')>();
return {
...actual,
parseConfig: mocks.parseConfig,
isTag: mocks.isTag,
paths: mocks.paths,
unmatchedPatterns: mocks.unmatchedPatterns,
uploadUrl: mocks.uploadUrl,
};
});
import { run } from '../src/run';
const baseConfig: Config = {
github_token: 'token',
github_ref: 'refs/tags/v1.0.0',
github_repository: 'owner/repo',
input_files: [],
input_draft: false,
input_prerelease: false,
input_preserve_order: false,
input_overwrite_files: true,
input_fail_on_unmatched_files: false,
input_generate_release_notes: false,
input_append_body: false,
input_make_latest: undefined,
};
const initialRelease: Release = {
id: 41,
upload_url: 'https://uploads.example.test/releases/41/assets{?name,label}',
html_url: 'https://example.test/releases/41',
tag_name: 'v1.0.0',
name: 'v1.0.0',
target_commitish: 'main',
draft: true,
prerelease: false,
assets: [{ id: 1, name: 'existing.zip' }],
};
const finalizedRelease: Release = {
...initialRelease,
id: 42,
upload_url: 'https://uploads.example.test/releases/42/assets{?name,label}',
html_url: 'https://example.test/releases/42',
draft: false,
};
const deferred = <T>() => {
let resolve!: (value: T) => void;
const promise = new Promise<T>((resolvePromise) => {
resolve = resolvePromise;
});
return { promise, resolve };
};
describe('run', () => {
let config: Config;
beforeEach(() => {
vi.clearAllMocks();
config = { ...baseConfig };
mocks.parseConfig.mockImplementation(() => config);
mocks.isTag.mockImplementation((ref: string) => ref.startsWith('refs/tags/'));
mocks.unmatchedPatterns.mockReturnValue([]);
mocks.paths.mockReturnValue([]);
mocks.uploadUrl.mockImplementation((url: string) => url.split('{')[0]);
mocks.getOctokit.mockReturnValue({ name: 'octokit' });
mocks.release.mockResolvedValue({ release: initialRelease, created: true });
mocks.finalizeRelease.mockResolvedValue(finalizedRelease);
mocks.upload.mockResolvedValue(undefined);
mocks.listReleaseAssets.mockResolvedValue([]);
vi.spyOn(console, 'log').mockImplementation(() => undefined);
vi.spyOn(console, 'warn').mockImplementation(() => undefined);
});
afterEach(() => {
vi.restoreAllMocks();
});
it.each([
['an explicit tag', { github_ref: 'refs/heads/main', input_tag_name: 'v1.0.0' }],
['a tag ref', { github_ref: 'refs/tags/v1.0.0', input_tag_name: undefined }],
[
'a draft without a tag',
{ github_ref: 'refs/heads/main', input_tag_name: undefined, input_draft: true },
],
])('accepts %s', async (_name, patch) => {
config = { ...config, ...patch };
await run();
expect(mocks.release).toHaveBeenCalledOnce();
expect(mocks.setFailed).not.toHaveBeenCalled();
});
it('reports the documented tag requirement for a non-tag ref', async () => {
config = { ...config, github_ref: 'refs/heads/main', input_tag_name: undefined };
await run();
expect(mocks.setFailed).toHaveBeenCalledWith('⚠️ GitHub Releases requires a tag');
expect(mocks.release).not.toHaveBeenCalled();
});
it('warns with each unmatched input pattern when strict matching is disabled', async () => {
config = { ...config, input_files: ['dist/*.zip'] };
mocks.unmatchedPatterns.mockReturnValue(['dist/*.zip']);
await run();
expect(console.warn).toHaveBeenCalledWith("🤔 Pattern 'dist/*.zip' does not match any files.");
expect(mocks.setFailed).not.toHaveBeenCalled();
});
it('fails with the unmatched input pattern when strict matching is enabled', async () => {
config = {
...config,
input_files: ['dist/*.zip'],
input_fail_on_unmatched_files: true,
};
mocks.unmatchedPatterns.mockReturnValue(['dist/*.zip']);
await run();
expect(mocks.setFailed).toHaveBeenCalledWith(
"⚠️ Pattern 'dist/*.zip' does not match any files.",
);
expect(mocks.release).not.toHaveBeenCalled();
});
it.each([
[false, 'warns', '🤔 dist/*.zip does not include a valid file.'],
[true, 'fails', '⚠️ dist/*.zip does not include a valid file.'],
])(
'%s strict matching %s when resolved patterns contain no files',
async (strict, _verb, message) => {
config = {
...config,
input_files: ['dist/*.zip'],
input_fail_on_unmatched_files: strict,
};
await run();
if (strict) {
expect(mocks.setFailed).toHaveBeenCalledWith(message);
expect(mocks.finalizeRelease).not.toHaveBeenCalled();
} else {
expect(console.warn).toHaveBeenCalledWith(message);
expect(mocks.finalizeRelease).toHaveBeenCalledOnce();
}
},
);
it('forwards valid files and the working directory through upload boundaries', async () => {
config = {
...config,
input_files: ['dist/*.zip'],
input_working_directory: 'fixture',
};
mocks.paths.mockReturnValue(['fixture/dist/action.zip']);
mocks.upload.mockResolvedValue({ id: 7 });
mocks.listReleaseAssets.mockResolvedValue([{ id: 7, name: 'action.zip' }]);
await run();
expect(mocks.paths).toHaveBeenCalledWith(['dist/*.zip'], 'fixture');
expect(mocks.upload).toHaveBeenCalledWith(
config,
mocks.releaser,
'https://uploads.example.test/releases/41/assets',
'fixture/dist/action.zip',
initialRelease.assets,
initialRelease.id,
);
});
it('starts uploads concurrently by default', async () => {
config = { ...config, input_files: ['one.zip', 'two.zip'] };
mocks.paths.mockReturnValue(['one.zip', 'two.zip']);
const first = deferred<{ id: number }>();
const second = deferred<{ id: number }>();
mocks.upload.mockImplementation((_config, _releaser, _url, path) =>
path === 'one.zip' ? first.promise : second.promise,
);
const result = run();
await Promise.resolve();
expect(mocks.upload).toHaveBeenCalledTimes(2);
first.resolve({ id: 1 });
second.resolve({ id: 2 });
await result;
});
it('waits for each upload when preserve_order is enabled', async () => {
config = {
...config,
input_files: ['one.zip', 'two.zip'],
input_preserve_order: true,
};
mocks.paths.mockReturnValue(['one.zip', 'two.zip']);
const first = deferred<{ id: number }>();
const second = deferred<{ id: number }>();
mocks.upload.mockImplementation((_config, _releaser, _url, path) =>
path === 'one.zip' ? first.promise : second.promise,
);
const result = run();
await Promise.resolve();
expect(mocks.upload).toHaveBeenCalledTimes(1);
first.resolve({ id: 1 });
await vi.waitFor(() => expect(mocks.upload).toHaveBeenCalledTimes(2));
second.resolve({ id: 2 });
await result;
});
it('keeps an existing draft unpublished until its upload completes', async () => {
config = {
...config,
input_draft: false,
input_prerelease: true,
input_files: ['asset.zip'],
};
mocks.release.mockResolvedValue({ release: initialRelease, created: false });
mocks.paths.mockReturnValue(['asset.zip']);
const pendingUpload = deferred<{ id: number }>();
mocks.upload.mockReturnValue(pendingUpload.promise);
const result = run();
await Promise.resolve();
expect(mocks.upload).toHaveBeenCalledOnce();
expect(mocks.finalizeRelease).not.toHaveBeenCalled();
pendingUpload.resolve({ id: 7 });
await result;
expect(mocks.finalizeRelease).toHaveBeenCalledWith(
config,
mocks.releaser,
initialRelease,
false,
);
});
it('uses the selected moving-tag release ID for upload, finalization, and outputs', async () => {
config = {
...config,
input_tag_name: 'nightly',
input_draft: false,
input_prerelease: true,
input_files: ['nightly.zip'],
};
const selectedRelease: Release = {
...initialRelease,
id: 77,
upload_url: 'https://uploads.example.test/releases/77/assets{?name,label}',
html_url: 'https://example.test/releases/77',
tag_name: 'nightly',
name: 'nightly',
prerelease: true,
};
const publishedRelease: Release = {
...selectedRelease,
draft: false,
};
mocks.release.mockResolvedValue({ release: selectedRelease, created: false });
mocks.paths.mockReturnValue(['nightly.zip']);
mocks.upload.mockResolvedValue({ id: 9 });
mocks.finalizeRelease.mockResolvedValue(publishedRelease);
mocks.listReleaseAssets.mockResolvedValue([{ id: 9, name: 'nightly.zip' }]);
await run();
expect(mocks.upload).toHaveBeenCalledWith(
config,
mocks.releaser,
'https://uploads.example.test/releases/77/assets',
'nightly.zip',
selectedRelease.assets,
77,
);
expect(mocks.finalizeRelease).toHaveBeenCalledWith(
config,
mocks.releaser,
selectedRelease,
false,
);
expect(mocks.listReleaseAssets).toHaveBeenCalledWith(config, mocks.releaser, publishedRelease);
expect(mocks.setOutput).toHaveBeenCalledWith('id', '77');
expect(mocks.setOutput).toHaveBeenCalledWith('url', publishedRelease.html_url);
});
it('leaves an existing draft recoverable when an upload fails', async () => {
config = {
...config,
input_draft: false,
input_prerelease: true,
input_files: ['asset.zip'],
};
mocks.release.mockResolvedValue({ release: initialRelease, created: false });
mocks.paths.mockReturnValue(['asset.zip']);
mocks.upload.mockRejectedValue(new Error('upload failed'));
await run();
expect(mocks.finalizeRelease).not.toHaveBeenCalled();
expect(mocks.setFailed).toHaveBeenCalledWith('upload failed');
});
it('finalizes after uploads and outputs only newly uploaded assets without uploader data', async () => {
config = { ...config, input_files: ['one.zip', 'skipped.zip', 'two.zip'] };
mocks.paths.mockReturnValue(['one.zip', 'skipped.zip', 'two.zip']);
mocks.upload
.mockResolvedValueOnce({ id: 7 })
.mockResolvedValueOnce(null)
.mockResolvedValueOnce({ id: 9 });
mocks.listReleaseAssets.mockResolvedValue([
{ id: 1, name: 'existing.zip', uploader: { login: 'someone' } },
{ id: 7, name: 'one.zip', size: 10, uploader: { login: 'bot' } },
{ id: 9, name: 'two.zip', label: 'Two', uploader: null },
]);
await run();
expect(mocks.release.mock.invocationCallOrder[0]).toBeLessThan(
mocks.upload.mock.invocationCallOrder[0],
);
expect(mocks.upload.mock.invocationCallOrder[2]).toBeLessThan(
mocks.finalizeRelease.mock.invocationCallOrder[0],
);
expect(mocks.finalizeRelease).toHaveBeenCalledWith(
config,
mocks.releaser,
initialRelease,
true,
);
expect(mocks.listReleaseAssets).toHaveBeenCalledWith(config, mocks.releaser, finalizedRelease);
expect(mocks.setOutput).toHaveBeenCalledWith('assets', [
{ id: 7, name: 'one.zip', size: 10 },
{ id: 9, name: 'two.zip', label: 'Two' },
]);
expect(mocks.setOutput).toHaveBeenCalledWith('url', finalizedRelease.html_url);
expect(mocks.setOutput).toHaveBeenCalledWith('id', '42');
expect(mocks.setOutput).toHaveBeenCalledWith('upload_url', finalizedRelease.upload_url);
});
it('outputs an empty asset list and skips refresh when no upload returns an ID', async () => {
config = { ...config, input_files: ['skipped.zip'] };
mocks.paths.mockReturnValue(['skipped.zip']);
mocks.upload.mockResolvedValue(undefined);
await run();
expect(mocks.listReleaseAssets).not.toHaveBeenCalled();
expect(mocks.setOutput).toHaveBeenCalledWith('assets', []);
});
it('configures rate-limit and abuse callbacks without making live requests', async () => {
await run();
const options = mocks.getOctokit.mock.calls[0][1];
const request = { method: 'GET', url: '/repos/owner/repo', request: { retryCount: 0 } };
expect(options.throttle.onRateLimit(5, request)).toBe(true);
expect(
options.throttle.onRateLimit(5, { ...request, request: { retryCount: 1 } }),
).toBeUndefined();
expect(options.throttle.onAbuseLimit(10, request)).toBeUndefined();
expect(console.warn).toHaveBeenCalledWith(
'Request quota exhausted for request GET /repos/owner/repo',
);
expect(console.warn).toHaveBeenCalledWith('Abuse detected for request GET /repos/owner/repo');
});
it.each([
[new Error('release failed'), 'release failed'],
['release failed', 'release failed'],
[{ message: 'release failed' }, 'release failed'],
[null, 'Unknown error'],
[undefined, 'Unknown error'],
])('normalizes a thrown value before reporting failure', async (thrown, expected) => {
mocks.parseConfig.mockImplementation(() => {
throw thrown;
});
await run();
expect(mocks.setFailed).toHaveBeenCalledWith(expected);
});
});
+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 { import {
alignAssetName, alignAssetName,
errorMessage,
expandHomePattern, expandHomePattern,
isTag, isTag,
normalizeFilePattern, normalizeFilePattern,
@@ -25,6 +26,26 @@ describe('util', () => {
'https://uploads.github.com/repos/octocat/Hello-World/releases/1/assets', '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', () => { describe('parseInputFiles', () => {
it('parses empty strings', () => { 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', () => { it('parses basic config with commitish', () => {
assert.deepStrictEqual( assert.deepStrictEqual(
parseConfig({ parseConfig({
+1 -1
View File
@@ -75,7 +75,7 @@ outputs:
assets: 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)" 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: runs:
using: "node20" using: "node24"
main: "dist/index.js" main: "dist/index.js"
branding: branding:
color: "green" 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", "name": "action-gh-release",
"version": "2.6.2", "version": "3.0.2",
"private": true, "private": true,
"description": "GitHub Action for creating GitHub Releases", "description": "GitHub Action for creating GitHub Releases",
"main": "lib/main.js", "main": "lib/main.js",
"scripts": { "scripts": {
"build": "esbuild src/main.ts --bundle --platform=node --format=cjs --target=node20 --outfile=dist/index.js --minify", "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=node20 --outfile=dist/index.js --sourcemap --keep-names", "build-debug": "esbuild src/main.ts --bundle --platform=node --format=cjs --target=node24 --outfile=dist/index.js --sourcemap --keep-names",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"test": "vitest --coverage", "test": "vitest --coverage",
"fmt": "prettier --write \"src/**/*.ts\" \"__tests__/**/*.ts\"", "fmt": "prettier --write \"src/**/*.ts\" \"__tests__/**/*.ts\"",
"fmtcheck": "prettier --check \"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": { "repository": {
"type": "git", "type": "git",
@@ -21,24 +21,24 @@
"actions" "actions"
], ],
"author": "softprops", "author": "softprops",
"engines": {
"node": ">=24"
},
"dependencies": { "dependencies": {
"@actions/core": "^3.0.0", "@actions/core": "^3.0.1",
"@actions/github": "^9.0.0", "@actions/github": "^9.1.1",
"@octokit/plugin-retry": "^8.1.0", "@octokit/plugin-retry": "^8.1.0",
"@octokit/plugin-throttling": "^11.0.3", "@octokit/plugin-throttling": "^11.0.3",
"glob": "^13.0.6", "glob": "^13.0.6",
"mime-types": "^3.0.2" "mime-types": "^3.0.2"
}, },
"devDependencies": { "devDependencies": {
"@types/glob": "^9.0.0",
"@types/mime-types": "^3.0.1", "@types/mime-types": "^3.0.1",
"@types/node": "^20.19.37", "@types/node": "^24",
"@vitest/coverage-v8": "^4.1.0", "@vitest/coverage-v8": "^4.1.9",
"esbuild": "^0.27.3", "esbuild": "^0.28.1",
"prettier": "3.8.1", "prettier": "3.9.0",
"ts-node": "^10.9.2", "typescript": "^7.0.2",
"typescript": "^5.9.3",
"typescript-formatter": "^7.2.2",
"vitest": "^4.0.4" "vitest": "^4.0.4"
} }
} }
+248 -89
View File
@@ -1,12 +1,63 @@
import { GitHub } from '@actions/github/lib/utils'; import { GitHub } from '@actions/github/lib/utils';
import { statSync } from 'fs'; import { statSync } from 'fs';
import { open } from 'fs/promises'; import { open, type FileHandle } from 'fs/promises';
import { lookup } from 'mime-types'; import { lookup } from 'mime-types';
import { basename } from 'path'; 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 GitHub = InstanceType<typeof GitHub>;
type UploadChunk = ArrayBuffer | Uint8Array<ArrayBufferLike>;
type UploadBody = ReadableStream<Uint8Array<ArrayBufferLike>>;
type UnknownRecord = Record<string, unknown>;
const asRecord = (value: unknown): UnknownRecord | undefined =>
typeof value === 'object' && value !== null ? (value as UnknownRecord) : undefined;
const getErrorStatus = (error: unknown): number | undefined => {
const errorRecord = asRecord(error);
if (typeof errorRecord?.status === 'number') {
return errorRecord.status;
}
const response = asRecord(errorRecord?.response);
return typeof response?.status === 'number' ? response.status : undefined;
};
const getResponseData = (error: unknown): UnknownRecord | undefined => {
const response = asRecord(asRecord(error)?.response);
return asRecord(response?.data);
};
const getErrorMessage = (error: unknown): string | undefined => {
const message = asRecord(error)?.message;
return typeof message === 'string' ? message : undefined;
};
const getRequestUrl = (error: unknown): string | undefined => {
const request = asRecord(asRecord(error)?.request);
return typeof request?.url === 'string' ? request.url : undefined;
};
const getValidationErrors = (error: unknown): unknown[] => {
const errors = getResponseData(error)?.errors;
return Array.isArray(errors) ? errors : [];
};
const hasValidationErrorCode = (error: unknown, code: string): boolean =>
asRecord(getValidationErrors(error)[0])?.code === code;
const fileUploadStream = (fileHandle: FileHandle): UploadBody => {
const source = fileHandle.readableWebStream() as ReadableStream<UploadChunk>;
return source.pipeThrough(
new TransformStream<UploadChunk, Uint8Array<ArrayBufferLike>>({
transform(chunk, controller) {
controller.enqueue(chunk instanceof Uint8Array ? chunk : new Uint8Array(chunk));
},
}),
);
};
export interface ReleaseAsset { export interface ReleaseAsset {
name: string; name: string;
mime: string; mime: string;
@@ -54,6 +105,43 @@ type ReleaseMutationParams = {
previous_tag_name?: string; 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 { export interface Releaser {
getReleaseByTag(params: { owner: string; repo: string; tag: string }): Promise<{ data: Release }>; getReleaseByTag(params: { owner: string; repo: string; tag: string }): Promise<{ data: Release }>;
@@ -82,7 +170,12 @@ export interface Releaser {
release_id: number; release_id: number;
}): Promise<Array<{ id: number; name: string; label?: string | null; [key: string]: any }>>; }): Promise<Array<{ id: number; name: string; label?: string | null; [key: string]: any }>>;
deleteReleaseAsset(params: { owner: string; repo: string; asset_id: number }): Promise<void>; deleteReleaseAsset(params: {
owner: string;
repo: string;
release_id: number;
asset_id: number;
}): Promise<void>;
deleteRelease(params: { owner: string; repo: string; release_id: number }): Promise<void>; deleteRelease(params: { owner: string; repo: string; release_id: number }): Promise<void>;
@@ -99,7 +192,7 @@ export interface Releaser {
size: number; size: number;
mime: string; mime: string;
token: string; token: string;
data: any; data: UploadBody;
}): Promise<{ status: number; data: any }>; }): Promise<{ status: number; data: any }>;
} }
@@ -215,9 +308,31 @@ export class GitHubReleaser implements Releaser {
async deleteReleaseAsset(params: { async deleteReleaseAsset(params: {
owner: string; owner: string;
repo: string; repo: string;
release_id: number;
asset_id: number; asset_id: number;
}): Promise<void> { }): Promise<void> {
await this.github.rest.repos.deleteReleaseAsset(params); const { release_id, ...githubParams } = params;
try {
await this.github.rest.repos.deleteReleaseAsset(githubParams);
} catch (error: unknown) {
if (getErrorStatus(error) !== 404) {
throw error;
}
try {
await this.github.request(
'DELETE /repos/{owner}/{repo}/releases/{release_id}/assets/{asset_id}',
params,
);
} catch (fallbackError: unknown) {
throw new AggregateError(
[error, fallbackError],
`Failed to delete release asset ${params.asset_id}. GitHub endpoint: ${errorMessage(
error,
)}; release-scoped endpoint: ${errorMessage(fallbackError)}`,
);
}
}
} }
async deleteRelease(params: { owner: string; repo: string; release_id: number }): Promise<void> { async deleteRelease(params: { owner: string; repo: string; release_id: number }): Promise<void> {
@@ -239,7 +354,7 @@ export class GitHubReleaser implements Releaser {
size: number; size: number;
mime: string; mime: string;
token: string; token: string;
data: any; data: UploadBody;
}): Promise<{ status: number; data: any }> { }): Promise<{ status: number; data: any }> {
return this.github.request({ return this.github.request({
method: 'POST', method: 'POST',
@@ -271,10 +386,10 @@ const releaseAssetMatchesName = (
asset: { name: string; label?: string | null }, asset: { name: string; label?: string | null },
): boolean => asset.name === name || asset.name === alignAssetName(name) || asset.label === name; ): boolean => asset.name === name || asset.name === alignAssetName(name) || asset.label === name;
const isReleaseAssetUpdateNotFound = (error: any): boolean => { const isReleaseAssetUpdateNotFound = (error: unknown): boolean => {
const errorStatus = error?.status ?? error?.response?.status; const errorStatus = getErrorStatus(error);
const requestUrl = error?.request?.url; const requestUrl = getRequestUrl(error);
const errorMessage = error?.message; const message = getErrorMessage(error);
const isReleaseAssetRequest = const isReleaseAssetRequest =
typeof requestUrl === 'string' && typeof requestUrl === 'string' &&
(/\/releases\/assets\//.test(requestUrl) || /\/releases\/\d+\/assets(?:\?|$)/.test(requestUrl)); (/\/releases\/assets\//.test(requestUrl) || /\/releases\/\d+\/assets(?:\?|$)/.test(requestUrl));
@@ -282,15 +397,15 @@ const isReleaseAssetUpdateNotFound = (error: any): boolean => {
return ( return (
errorStatus === 404 && errorStatus === 404 &&
(isReleaseAssetRequest || (isReleaseAssetRequest ||
(typeof errorMessage === 'string' && errorMessage.includes('update-a-release-asset'))) (typeof message === 'string' && message.includes('update-a-release-asset')))
); );
}; };
const isImmutableReleaseAssetUploadFailure = (error: any): boolean => { const isImmutableReleaseAssetUploadFailure = (error: unknown): boolean => {
const errorStatus = error?.status ?? error?.response?.status; const errorStatus = getErrorStatus(error);
const errorMessage = error?.response?.data?.message ?? error?.message; const message = getResponseData(error)?.message ?? getErrorMessage(error);
return errorStatus === 422 && /immutable release/i.test(String(errorMessage)); return errorStatus === 422 && /immutable release/i.test(String(message));
}; };
const immutableReleaseAssetUploadMessage = ( const immutableReleaseAssetUploadMessage = (
@@ -307,11 +422,10 @@ export const upload = async (
url: string, url: string,
path: string, path: string,
currentAssets: Array<{ id: number; name: string; label?: string | null }>, currentAssets: Array<{ id: number; name: string; label?: string | null }>,
releaseId: number,
): Promise<any> => { ): Promise<any> => {
const [owner, repo] = config.github_repository.split('/'); const [owner, repo] = config.github_repository.split('/');
const { name, mime, size } = asset(path); const { name, mime, size } = asset(path);
const releaseIdMatch = url.match(/\/releases\/(\d+)\/assets/);
const releaseId = releaseIdMatch ? Number(releaseIdMatch[1]) : undefined;
const currentAsset = currentAssets.find( const currentAsset = currentAssets.find(
// GitHub can rewrite uploaded asset names, so compare against both the raw name // GitHub can rewrite uploaded asset names, so compare against both the raw name
// GitHub returns and the restored label we set when available. // GitHub returns and the restored label we set when available.
@@ -327,6 +441,7 @@ export const upload = async (
asset_id: currentAsset.id || 1, asset_id: currentAsset.id || 1,
owner, owner,
repo, repo,
release_id: releaseId,
}); });
} }
} }
@@ -367,7 +482,7 @@ export const upload = async (
size, size,
mime, mime,
token: config.github_token, token: config.github_token,
data: fh.readableWebStream({ type: 'bytes' }), data: fileUploadStream(fh),
}); });
} finally { } finally {
await fh.close(); await fh.close();
@@ -399,8 +514,8 @@ export const upload = async (
try { try {
return await updateAssetLabel(uploadedAsset.id); return await updateAssetLabel(uploadedAsset.id);
} catch (error: any) { } catch (error: unknown) {
const errorStatus = error?.status ?? error?.response?.status; const errorStatus = getErrorStatus(error);
if (errorStatus === 404 && releaseId !== undefined) { if (errorStatus === 404 && releaseId !== undefined) {
try { try {
@@ -437,9 +552,8 @@ export const upload = async (
try { try {
return await handleUploadedAsset(await uploadAsset()); return await handleUploadedAsset(await uploadAsset());
} catch (error: any) { } catch (error: unknown) {
const errorStatus = error?.status ?? error?.response?.status; const errorStatus = getErrorStatus(error);
const errorData = error?.response?.data;
if (isImmutableReleaseAssetUploadFailure(error)) { if (isImmutableReleaseAssetUploadFailure(error)) {
throw new Error(immutableReleaseAssetUploadMessage(name, config.input_prerelease)); throw new Error(immutableReleaseAssetUploadMessage(name, config.input_prerelease));
@@ -467,7 +581,7 @@ export const upload = async (
if ( if (
config.input_overwrite_files !== false && config.input_overwrite_files !== false &&
errorStatus === 422 && errorStatus === 422 &&
errorData?.errors?.[0]?.code === 'already_exists' && hasValidationErrorCode(error, 'already_exists') &&
releaseId !== undefined releaseId !== undefined
) { ) {
console.log( console.log(
@@ -485,6 +599,7 @@ export const upload = async (
await releaser.deleteReleaseAsset({ await releaser.deleteReleaseAsset({
owner, owner,
repo, repo,
release_id: releaseId,
asset_id: latestAsset.id, asset_id: latestAsset.id,
}); });
return await handleUploadedAsset(await uploadAsset()); return await handleUploadedAsset(await uploadAsset());
@@ -517,23 +632,36 @@ export const release = async (
if (generate_release_notes && previous_tag_name) { if (generate_release_notes && previous_tag_name) {
console.log(`📝 Generating release notes using previous tag ${previous_tag_name}`); console.log(`📝 Generating release notes using previous tag ${previous_tag_name}`);
} }
let _release: Release | undefined;
try { try {
const _release: Release | undefined = await findTagFromReleases(releaser, owner, repo, tag); _release = await findTagFromReleases(releaser, owner, repo, tag, maxRetries);
} catch (error) {
if (_release === undefined) { if (getErrorStatus(error) === 404) {
return await createRelease( const diagnostic = releaseLookup404Message(owner, repo, error);
tag, console.log(`⚠️ ${diagnostic}`);
config, throw new ReleaseAccessError(diagnostic, error);
releaser,
owner,
repo,
discussion_category_name,
generate_release_notes,
maxRetries,
previous_tag_name,
);
} }
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!; let existingRelease: Release = _release!;
console.log(`Found release ${existingRelease.name} (with id=${existingRelease.id})`); console.log(`Found release ${existingRelease.name} (with id=${existingRelease.id})`);
@@ -591,7 +719,10 @@ export const release = async (
created: false, created: false,
}; };
} catch (error) { } catch (error) {
if (error.status !== 404) { if (error instanceof ReleaseCreationError) {
throw error;
}
if (getErrorStatus(error) !== 404) {
console.log( console.log(
`⚠️ Unexpected error fetching GitHub release for tag ${config.github_ref}: ${error}`, `⚠️ Unexpected error fetching GitHub release for tag ${config.github_ref}: ${error}`,
); );
@@ -719,14 +850,16 @@ export const listReleaseAssets = async (
/** /**
* Finds a release by tag name. * Finds a release by tag name.
* *
* Uses the direct getReleaseByTag API for O(1) lookup instead of iterating * Uses the direct getReleaseByTag API for O(1) lookup. Because GitHub does not
* through all releases. This also avoids GitHub's API pagination limit of * expose draft releases through that endpoint, a 404 falls back to a bounded
* 10000 results which would cause failures for repositories with many releases. * 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 releaser - The GitHub API wrapper for release operations
* @param owner - The owner of the repository * @param owner - The owner of the repository
* @param repo - The name of the repository * @param repo - The name of the repository
* @param tag - The tag name to search for * @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 * @returns The release with the given tag name, or undefined if no release with that tag name is found
*/ */
export async function findTagFromReleases( export async function findTagFromReleases(
@@ -734,18 +867,35 @@ export async function findTagFromReleases(
owner: string, owner: string,
repo: string, repo: string,
tag: string, tag: string,
listingAttempts: number = 1,
): Promise<Release | undefined> { ): Promise<Release | undefined> {
try { try {
const { data: release } = await releaser.getReleaseByTag({ owner, repo, tag }); const { data: release } = await releaser.getReleaseByTag({ owner, repo, tag });
return release; return release;
} catch (error) { } catch (error) {
// Release not found (404) or other error - return undefined to allow creation if (getErrorStatus(error) !== 404) {
if (error.status === 404) { throw error;
return undefined;
} }
// 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; const CREATED_RELEASE_DISCOVERY_RETRY_DELAY_MS = 1000;
@@ -797,33 +947,31 @@ function pickCanonicalRelease(
})[0]; })[0];
} }
async function cleanupDuplicateDraftReleases( async function cleanupCreatedDuplicateDraftRelease(
releaser: Releaser, releaser: Releaser,
owner: string, owner: string,
repo: string, repo: string,
tag: string, tag: string,
canonicalReleaseId: number, canonicalReleaseId: number,
releases: Release[], createdRelease: Release,
): Promise<void> { ): Promise<void> {
const uniqueReleases = Array.from( if (
new Map(releases.map((release) => [release.id, release])).values(), createdRelease.id === canonicalReleaseId ||
); !createdRelease.draft ||
createdRelease.assets.length > 0
) {
return;
}
for (const duplicate of uniqueReleases) { try {
if (duplicate.id === canonicalReleaseId || !duplicate.draft || duplicate.assets.length > 0) { console.log(`🧹 Removing duplicate draft release ${createdRelease.id} for tag ${tag}...`);
continue; await releaser.deleteRelease({
} owner,
repo,
try { release_id: createdRelease.id,
console.log(`🧹 Removing duplicate draft release ${duplicate.id} for tag ${tag}...`); });
await releaser.deleteRelease({ } catch (error) {
owner, console.warn(`error deleting duplicate release ${createdRelease.id}: ${error}`);
repo,
release_id: duplicate.id,
});
} catch (error) {
console.warn(`error deleting duplicate release ${duplicate.id}: ${error}`);
}
} }
} }
@@ -840,7 +988,7 @@ async function canonicalizeCreatedRelease(
for (let attempt = 1; attempt <= attempts; attempt += 1) { for (let attempt = 1; attempt <= attempts; attempt += 1) {
let releaseByTag: Release | undefined; let releaseByTag: Release | undefined;
try { try {
releaseByTag = await findTagFromReleases(releaser, owner, repo, tag); releaseByTag = await findTagFromReleases(releaser, owner, repo, tag, 0);
} catch (error) { } catch (error) {
console.warn(`error reloading release for tag ${tag}: ${error}`); console.warn(`error reloading release for tag ${tag}: ${error}`);
} }
@@ -860,10 +1008,19 @@ async function canonicalizeCreatedRelease(
); );
} }
await cleanupDuplicateDraftReleases(releaser, owner, repo, tag, canonicalRelease.id, [ const refreshedCreatedRelease = recentReleases.find(
createdRelease, (release) => release.id === createdRelease.id,
...recentReleases, );
]); if (refreshedCreatedRelease) {
await cleanupCreatedDuplicateDraftRelease(
releaser,
owner,
repo,
tag,
canonicalRelease.id,
refreshedCreatedRelease,
);
}
return canonicalRelease; return canonicalRelease;
} }
@@ -933,12 +1090,13 @@ async function createRelease(
release: canonicalRelease, release: canonicalRelease,
created: canonicalRelease.id === createdRelease.data.id, created: canonicalRelease.id === createdRelease.data.id,
}; };
} catch (error) { } catch (error: unknown) {
const errorStatus = getErrorStatus(error);
// presume a race with competing matrix runs // presume a race with competing matrix runs
console.log(`⚠️ GitHub release failed with status: ${error.status}`); console.log(`⚠️ GitHub release failed with status: ${errorStatus}`);
console.log(`${JSON.stringify(error.response.data)}`); console.log(errorMessage(error));
switch (error.status) { switch (errorStatus) {
case 403: case 403:
console.log( console.log(
'Skip retry — your GitHub token/PAT does not have the required permission to create a release', 'Skip retry — your GitHub token/PAT does not have the required permission to create a release',
@@ -946,13 +1104,13 @@ async function createRelease(
throw error; throw error;
case 404: case 404:
console.log('Skip retry - discussion category mismatch'); const diagnostic = releaseCreation404Message(owner, repo, discussion_category_name, error);
throw error; console.log(`Skip retry — ${diagnostic}`);
throw new ReleaseCreationError(diagnostic, error);
case 422: case 422:
// Check if this is a race condition with "already_exists" error // Check if this is a race condition with "already_exists" error
const errorData = error.response?.data; if (hasValidationErrorCode(error, 'already_exists')) {
if (errorData?.errors?.[0]?.code === 'already_exists') {
console.log( console.log(
'⚠️ Release already exists (race condition detected), retrying to find and update existing release...', '⚠️ Release already exists (race condition detected), retrying to find and update existing release...',
); );
@@ -969,16 +1127,17 @@ async function createRelease(
} }
} }
function isTagCreationBlockedError(error: any): boolean { function isTagCreationBlockedError(error: unknown): boolean {
const errors = error?.response?.data?.errors; if (getErrorStatus(error) !== 422) {
if (!Array.isArray(errors) || error?.status !== 422) {
return false; return false;
} }
return errors.some( return getValidationErrors(error).some((validationError) => {
({ field, message }: { field?: string; message?: string }) => const errorRecord = asRecord(validationError);
field === 'pre_receive' && return (
typeof message === 'string' && errorRecord?.field === 'pre_receive' &&
message.includes('creations being restricted'), typeof errorRecord.message === 'string' &&
); errorRecord.message.includes('creations being restricted')
);
});
} }
+2 -113
View File
@@ -1,114 +1,3 @@
import { setFailed, setOutput } from '@actions/core'; import { run } from './run';
import { getOctokit } from '@actions/github';
import { GitHubReleaser, release, finalizeRelease, upload, listReleaseAssets } from './github';
import { isTag, parseConfig, paths, unmatchedPatterns, uploadUrl } from './util';
import { env } from 'process'; void run();
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();
+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; 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 => { export const uploadUrl = (url: string): string => {
const templateMarkerPos = url.indexOf('{'); const templateMarkerPos = url.indexOf('{');
if (templateMarkerPos > -1) { if (templateMarkerPos > -1) {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"compilerOptions": { "compilerOptions": {
"useUnknownInCatchVariables": false, "useUnknownInCatchVariables": true,
/* Basic Options */ /* Basic Options */
// "incremental": true, /* Enable incremental compilation */ // "incremental": true, /* Enable incremental compilation */
"target": "es2022", "target": "es2022",
+8 -1
View File
@@ -4,7 +4,14 @@ export default defineConfig({
test: { test: {
environment: 'node', environment: 'node',
coverage: { coverage: {
reporter: ['text', 'lcov'], reporter: ['text', 'json-summary', 'lcov'],
include: ['src/**/*.ts'],
thresholds: {
statements: 94,
branches: 90,
functions: 95,
lines: 94,
},
}, },
include: ['__tests__/**/*.ts'], include: ['__tests__/**/*.ts'],
}, },