mirror of
https://gh-proxy.org/https://github.com/softprops/action-gh-release.git
synced 2026-07-16 07:23:00 +08:00
Compare commits
43 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1c214ab373 | |||
| d100e75a45 | |||
| 81bcd9dd59 | |||
| b05950fac2 | |||
| 3d0d9888cb | |||
| 7e13ed4ac5 | |||
| e6c70a53cf | |||
| f345337888 | |||
| d8a89a2066 | |||
| 45ece40c31 | |||
| f6b913c3f9 | |||
| 15f193d7d8 | |||
| cc8268d46a | |||
| fd0ed1e85b | |||
| 132dbbba49 | |||
| 3743f4e7d1 | |||
| 566ec910ab | |||
| 718ea10b13 | |||
| f1a938b9d8 | |||
| 0066ead0de | |||
| dc643cac62 | |||
| 85ee99b6b2 | |||
| 9ed3cf9a68 | |||
| 3efcac8951 | |||
| 05d6b9164a | |||
| 403a5240f3 | |||
| 437e073e78 | |||
| 5a1e17dcea | |||
| 2bc819c87a | |||
| c90b8762f6 | |||
| 4dd30955cc | |||
| be9cff7d8e | |||
| 07399f3317 | |||
| 93e4a0038b | |||
| b430933298 | |||
| c2e35e05a7 | |||
| 3bb12739c2 | |||
| c34030fec9 | |||
| 8975bd05c0 | |||
| f71937f44d | |||
| 3f0d239d58 | |||
| 153bb8e044 | |||
| 569deb874d |
@@ -1 +0,0 @@
|
|||||||
ko_fi: softprops
|
|
||||||
@@ -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)"
|
||||||
|
|||||||
@@ -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
@@ -1 +1 @@
|
|||||||
nodejs 24.11.0
|
nodejs 24.18.0
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -1,3 +1,81 @@
|
|||||||
|
## 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` is the final `v2` release and is no longer maintained or supported. Upgrade
|
||||||
|
to `v3` for the supported Node 24 runtime and current fixes.
|
||||||
|
|
||||||
|
## What's Changed
|
||||||
|
|
||||||
|
### Other Changes 🔄
|
||||||
|
|
||||||
|
* chore(deps): bump picomatch from 4.0.3 to 4.0.4 by @dependabot[bot] in https://github.com/softprops/action-gh-release/pull/775
|
||||||
|
* chore(deps): bump brace-expansion from 5.0.4 to 5.0.5 by @dependabot[bot] in https://github.com/softprops/action-gh-release/pull/777
|
||||||
|
* chore(deps): bump vite from 8.0.0 to 8.0.5 by @dependabot[bot] in https://github.com/softprops/action-gh-release/pull/781
|
||||||
|
|
||||||
|
## 2.6.1
|
||||||
|
|
||||||
|
`2.6.1` is a patch release focused on restoring linked discussion thread creation when
|
||||||
|
`discussion_category_name` is set. It fixes `#764`, where the draft-first publish flow
|
||||||
|
stopped carrying the discussion category through the final publish step.
|
||||||
|
|
||||||
|
If you still hit an issue after upgrading, please open a report with the bug template and include a minimal repro or sanitized workflow snippet where possible.
|
||||||
|
|
||||||
|
## What's Changed
|
||||||
|
|
||||||
|
### Bug fixes 🐛
|
||||||
|
|
||||||
|
* fix: preserve discussion category on publish by @chenrui333 in https://github.com/softprops/action-gh-release/pull/765
|
||||||
|
|
||||||
## 2.6.0
|
## 2.6.0
|
||||||
|
|
||||||
`2.6.0` is a minor release centered on `previous_tag` support for `generate_release_notes`,
|
`2.6.0` is a minor release centered on `previous_tag` support for `generate_release_notes`,
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
+1251
-57
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,11 @@
|
|||||||
|
import { expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const run = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
|
||||||
|
|
||||||
|
vi.mock('../src/run', () => ({ run }));
|
||||||
|
|
||||||
|
it('starts the action orchestration', async () => {
|
||||||
|
await import('../src/main');
|
||||||
|
|
||||||
|
expect(run).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
import { getOctokit } from '@actions/github';
|
||||||
|
import { createServer, type Server } from 'http';
|
||||||
|
import { type AddressInfo } from 'net';
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
import { GitHubReleaser, release, type Release } from '../src/github';
|
||||||
|
import { parseConfig, type Config } from '../src/util';
|
||||||
|
|
||||||
|
type CapturedRequest = {
|
||||||
|
path: string;
|
||||||
|
authorization: string | undefined;
|
||||||
|
contentType: string | undefined;
|
||||||
|
body: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeServer = async (server: Server): Promise<void> => {
|
||||||
|
server.closeIdleConnections();
|
||||||
|
server.closeAllConnections();
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
server.close((error) => (error ? reject(error) : resolve()));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('release creation transport', () => {
|
||||||
|
let server: Server;
|
||||||
|
let baseUrl: string;
|
||||||
|
const requests: CapturedRequest[] = [];
|
||||||
|
const releases = new Map<string, Release>();
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
server = createServer(async (request, response) => {
|
||||||
|
const url = new URL(request.url || '/', 'http://127.0.0.1');
|
||||||
|
const tagMatch = url.pathname.match(/^\/repos\/owner\/remote\/releases\/tags\/(.+)$/);
|
||||||
|
if (request.method === 'GET' && tagMatch) {
|
||||||
|
const release = releases.get(decodeURIComponent(tagMatch[1]));
|
||||||
|
response.writeHead(release ? 200 : 404, { 'content-type': 'application/json' });
|
||||||
|
response.end(JSON.stringify(release ?? { message: 'Not Found' }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.method === 'GET' && url.pathname === '/repos/owner/remote/releases') {
|
||||||
|
response.writeHead(200, { 'content-type': 'application/json' });
|
||||||
|
response.end(JSON.stringify([...releases.values()]));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.method === 'POST' && url.pathname === '/repos/owner/remote/releases') {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
for await (const chunk of request) {
|
||||||
|
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||||
|
}
|
||||||
|
const body = JSON.parse(Buffer.concat(chunks).toString('utf8')) as Record<string, unknown>;
|
||||||
|
requests.push({
|
||||||
|
path: url.pathname,
|
||||||
|
authorization: request.headers.authorization,
|
||||||
|
contentType: request.headers['content-type'],
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
const tag = String(body.tag_name);
|
||||||
|
const createdRelease: Release = {
|
||||||
|
id: requests.length,
|
||||||
|
upload_url: `http://127.0.0.1/uploads/${requests.length}`,
|
||||||
|
html_url: `http://127.0.0.1/releases/${requests.length}`,
|
||||||
|
tag_name: tag,
|
||||||
|
name: String(body.name),
|
||||||
|
body: typeof body.body === 'string' ? body.body : null,
|
||||||
|
target_commitish: 'main',
|
||||||
|
draft: Boolean(body.draft),
|
||||||
|
prerelease: Boolean(body.prerelease),
|
||||||
|
assets: [],
|
||||||
|
};
|
||||||
|
releases.set(tag, createdRelease);
|
||||||
|
response.writeHead(201, { 'content-type': 'application/json' });
|
||||||
|
response.end(JSON.stringify(createdRelease));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
response.writeHead(500, { 'content-type': 'application/json' });
|
||||||
|
response.end(
|
||||||
|
JSON.stringify({ message: `Unexpected route ${request.method} ${url.pathname}` }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
server.once('error', reject);
|
||||||
|
server.listen(0, '127.0.0.1', resolve);
|
||||||
|
});
|
||||||
|
const address = server.address() as AddressInfo;
|
||||||
|
baseUrl = `http://127.0.0.1:${address.port}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await closeServer(server);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('serializes user-facing category inputs through the real Octokit request path', async () => {
|
||||||
|
const parsedEmptyCategory = parseConfig({
|
||||||
|
INPUT_DISCUSSION_CATEGORY_NAME: '',
|
||||||
|
}).input_discussion_category_name;
|
||||||
|
expect(parsedEmptyCategory).toBeUndefined();
|
||||||
|
|
||||||
|
const cases: Array<{
|
||||||
|
name: string;
|
||||||
|
categoryProperty: 'absent' | 'present';
|
||||||
|
category: string | undefined;
|
||||||
|
expectedCategory: string | undefined;
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
name: 'absent-category',
|
||||||
|
categoryProperty: 'absent',
|
||||||
|
category: undefined,
|
||||||
|
expectedCategory: undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'undefined-category',
|
||||||
|
categoryProperty: 'present',
|
||||||
|
category: undefined,
|
||||||
|
expectedCategory: undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'empty-input-category',
|
||||||
|
categoryProperty: 'present',
|
||||||
|
category: parsedEmptyCategory,
|
||||||
|
expectedCategory: undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'valid-category',
|
||||||
|
categoryProperty: 'present',
|
||||||
|
category: 'Announcements',
|
||||||
|
expectedCategory: 'Announcements',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const testCase of cases) {
|
||||||
|
const config: Config = {
|
||||||
|
github_token: 'not-a-real-token',
|
||||||
|
github_ref: 'refs/heads/main',
|
||||||
|
github_repository: 'owner/remote',
|
||||||
|
input_tag_name: testCase.name,
|
||||||
|
input_name: `Release ${testCase.name}`,
|
||||||
|
input_files: [],
|
||||||
|
input_draft: false,
|
||||||
|
input_prerelease: true,
|
||||||
|
input_fail_on_unmatched_files: false,
|
||||||
|
input_generate_release_notes: false,
|
||||||
|
input_append_body: false,
|
||||||
|
input_make_latest: undefined,
|
||||||
|
};
|
||||||
|
if (testCase.categoryProperty === 'present') {
|
||||||
|
config.input_discussion_category_name = testCase.category;
|
||||||
|
}
|
||||||
|
|
||||||
|
const releaser = new GitHubReleaser(
|
||||||
|
getOctokit(config.github_token, {
|
||||||
|
baseUrl,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await expect(release(config, releaser, 1)).resolves.toMatchObject({
|
||||||
|
release: { tag_name: testCase.name },
|
||||||
|
created: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const request = requests.at(-1);
|
||||||
|
expect(request).toMatchObject({
|
||||||
|
path: '/repos/owner/remote/releases',
|
||||||
|
authorization: 'token not-a-real-token',
|
||||||
|
contentType: 'application/json; charset=utf-8',
|
||||||
|
body: {
|
||||||
|
tag_name: testCase.name,
|
||||||
|
name: `Release ${testCase.name}`,
|
||||||
|
draft: false,
|
||||||
|
prerelease: true,
|
||||||
|
generate_release_notes: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (testCase.expectedCategory) {
|
||||||
|
expect(request?.body.discussion_category_name).toBe(testCase.expectedCategory);
|
||||||
|
} else {
|
||||||
|
expect(request?.body).not.toHaveProperty('discussion_category_name');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,433 @@
|
|||||||
|
import type { Config } from '../src/util';
|
||||||
|
import type { Release } from '../src/github';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
setFailed: vi.fn(),
|
||||||
|
setOutput: vi.fn(),
|
||||||
|
getOctokit: vi.fn(),
|
||||||
|
GitHubReleaser: vi.fn(function () {
|
||||||
|
return mocks.releaser;
|
||||||
|
}),
|
||||||
|
releaser: { name: 'releaser' },
|
||||||
|
release: vi.fn(),
|
||||||
|
finalizeRelease: vi.fn(),
|
||||||
|
upload: vi.fn(),
|
||||||
|
listReleaseAssets: vi.fn(),
|
||||||
|
parseConfig: vi.fn(),
|
||||||
|
isTag: vi.fn(),
|
||||||
|
paths: vi.fn(),
|
||||||
|
unmatchedPatterns: vi.fn(),
|
||||||
|
uploadUrl: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@actions/core', () => ({
|
||||||
|
setFailed: mocks.setFailed,
|
||||||
|
setOutput: mocks.setOutput,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@actions/github', () => ({ getOctokit: mocks.getOctokit }));
|
||||||
|
|
||||||
|
vi.mock('../src/github', () => ({
|
||||||
|
GitHubReleaser: mocks.GitHubReleaser,
|
||||||
|
release: mocks.release,
|
||||||
|
finalizeRelease: mocks.finalizeRelease,
|
||||||
|
upload: mocks.upload,
|
||||||
|
listReleaseAssets: mocks.listReleaseAssets,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../src/util', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('../src/util')>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
parseConfig: mocks.parseConfig,
|
||||||
|
isTag: mocks.isTag,
|
||||||
|
paths: mocks.paths,
|
||||||
|
unmatchedPatterns: mocks.unmatchedPatterns,
|
||||||
|
uploadUrl: mocks.uploadUrl,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
import { run } from '../src/run';
|
||||||
|
|
||||||
|
const baseConfig: Config = {
|
||||||
|
github_token: 'token',
|
||||||
|
github_ref: 'refs/tags/v1.0.0',
|
||||||
|
github_repository: 'owner/repo',
|
||||||
|
input_files: [],
|
||||||
|
input_draft: false,
|
||||||
|
input_prerelease: false,
|
||||||
|
input_preserve_order: false,
|
||||||
|
input_overwrite_files: true,
|
||||||
|
input_fail_on_unmatched_files: false,
|
||||||
|
input_generate_release_notes: false,
|
||||||
|
input_append_body: false,
|
||||||
|
input_make_latest: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
const initialRelease: Release = {
|
||||||
|
id: 41,
|
||||||
|
upload_url: 'https://uploads.example.test/releases/41/assets{?name,label}',
|
||||||
|
html_url: 'https://example.test/releases/41',
|
||||||
|
tag_name: 'v1.0.0',
|
||||||
|
name: 'v1.0.0',
|
||||||
|
target_commitish: 'main',
|
||||||
|
draft: true,
|
||||||
|
prerelease: false,
|
||||||
|
assets: [{ id: 1, name: 'existing.zip' }],
|
||||||
|
};
|
||||||
|
|
||||||
|
const finalizedRelease: Release = {
|
||||||
|
...initialRelease,
|
||||||
|
id: 42,
|
||||||
|
upload_url: 'https://uploads.example.test/releases/42/assets{?name,label}',
|
||||||
|
html_url: 'https://example.test/releases/42',
|
||||||
|
draft: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const deferred = <T>() => {
|
||||||
|
let resolve!: (value: T) => void;
|
||||||
|
const promise = new Promise<T>((resolvePromise) => {
|
||||||
|
resolve = resolvePromise;
|
||||||
|
});
|
||||||
|
return { promise, resolve };
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('run', () => {
|
||||||
|
let config: Config;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
config = { ...baseConfig };
|
||||||
|
mocks.parseConfig.mockImplementation(() => config);
|
||||||
|
mocks.isTag.mockImplementation((ref: string) => ref.startsWith('refs/tags/'));
|
||||||
|
mocks.unmatchedPatterns.mockReturnValue([]);
|
||||||
|
mocks.paths.mockReturnValue([]);
|
||||||
|
mocks.uploadUrl.mockImplementation((url: string) => url.split('{')[0]);
|
||||||
|
mocks.getOctokit.mockReturnValue({ name: 'octokit' });
|
||||||
|
mocks.release.mockResolvedValue({ release: initialRelease, created: true });
|
||||||
|
mocks.finalizeRelease.mockResolvedValue(finalizedRelease);
|
||||||
|
mocks.upload.mockResolvedValue(undefined);
|
||||||
|
mocks.listReleaseAssets.mockResolvedValue([]);
|
||||||
|
vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
||||||
|
vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['an explicit tag', { github_ref: 'refs/heads/main', input_tag_name: 'v1.0.0' }],
|
||||||
|
['a tag ref', { github_ref: 'refs/tags/v1.0.0', input_tag_name: undefined }],
|
||||||
|
[
|
||||||
|
'a draft without a tag',
|
||||||
|
{ github_ref: 'refs/heads/main', input_tag_name: undefined, input_draft: true },
|
||||||
|
],
|
||||||
|
])('accepts %s', async (_name, patch) => {
|
||||||
|
config = { ...config, ...patch };
|
||||||
|
|
||||||
|
await run();
|
||||||
|
|
||||||
|
expect(mocks.release).toHaveBeenCalledOnce();
|
||||||
|
expect(mocks.setFailed).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports the documented tag requirement for a non-tag ref', async () => {
|
||||||
|
config = { ...config, github_ref: 'refs/heads/main', input_tag_name: undefined };
|
||||||
|
|
||||||
|
await run();
|
||||||
|
|
||||||
|
expect(mocks.setFailed).toHaveBeenCalledWith('⚠️ GitHub Releases requires a tag');
|
||||||
|
expect(mocks.release).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('warns with each unmatched input pattern when strict matching is disabled', async () => {
|
||||||
|
config = { ...config, input_files: ['dist/*.zip'] };
|
||||||
|
mocks.unmatchedPatterns.mockReturnValue(['dist/*.zip']);
|
||||||
|
|
||||||
|
await run();
|
||||||
|
|
||||||
|
expect(console.warn).toHaveBeenCalledWith("🤔 Pattern 'dist/*.zip' does not match any files.");
|
||||||
|
expect(mocks.setFailed).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails with the unmatched input pattern when strict matching is enabled', async () => {
|
||||||
|
config = {
|
||||||
|
...config,
|
||||||
|
input_files: ['dist/*.zip'],
|
||||||
|
input_fail_on_unmatched_files: true,
|
||||||
|
};
|
||||||
|
mocks.unmatchedPatterns.mockReturnValue(['dist/*.zip']);
|
||||||
|
|
||||||
|
await run();
|
||||||
|
|
||||||
|
expect(mocks.setFailed).toHaveBeenCalledWith(
|
||||||
|
"⚠️ Pattern 'dist/*.zip' does not match any files.",
|
||||||
|
);
|
||||||
|
expect(mocks.release).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[false, 'warns', '🤔 dist/*.zip does not include a valid file.'],
|
||||||
|
[true, 'fails', '⚠️ dist/*.zip does not include a valid file.'],
|
||||||
|
])(
|
||||||
|
'%s strict matching %s when resolved patterns contain no files',
|
||||||
|
async (strict, _verb, message) => {
|
||||||
|
config = {
|
||||||
|
...config,
|
||||||
|
input_files: ['dist/*.zip'],
|
||||||
|
input_fail_on_unmatched_files: strict,
|
||||||
|
};
|
||||||
|
|
||||||
|
await run();
|
||||||
|
|
||||||
|
if (strict) {
|
||||||
|
expect(mocks.setFailed).toHaveBeenCalledWith(message);
|
||||||
|
expect(mocks.finalizeRelease).not.toHaveBeenCalled();
|
||||||
|
} else {
|
||||||
|
expect(console.warn).toHaveBeenCalledWith(message);
|
||||||
|
expect(mocks.finalizeRelease).toHaveBeenCalledOnce();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it('forwards valid files and the working directory through upload boundaries', async () => {
|
||||||
|
config = {
|
||||||
|
...config,
|
||||||
|
input_files: ['dist/*.zip'],
|
||||||
|
input_working_directory: 'fixture',
|
||||||
|
};
|
||||||
|
mocks.paths.mockReturnValue(['fixture/dist/action.zip']);
|
||||||
|
mocks.upload.mockResolvedValue({ id: 7 });
|
||||||
|
mocks.listReleaseAssets.mockResolvedValue([{ id: 7, name: 'action.zip' }]);
|
||||||
|
|
||||||
|
await run();
|
||||||
|
|
||||||
|
expect(mocks.paths).toHaveBeenCalledWith(['dist/*.zip'], 'fixture');
|
||||||
|
expect(mocks.upload).toHaveBeenCalledWith(
|
||||||
|
config,
|
||||||
|
mocks.releaser,
|
||||||
|
'https://uploads.example.test/releases/41/assets',
|
||||||
|
'fixture/dist/action.zip',
|
||||||
|
initialRelease.assets,
|
||||||
|
initialRelease.id,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts uploads concurrently by default', async () => {
|
||||||
|
config = { ...config, input_files: ['one.zip', 'two.zip'] };
|
||||||
|
mocks.paths.mockReturnValue(['one.zip', 'two.zip']);
|
||||||
|
const first = deferred<{ id: number }>();
|
||||||
|
const second = deferred<{ id: number }>();
|
||||||
|
mocks.upload.mockImplementation((_config, _releaser, _url, path) =>
|
||||||
|
path === 'one.zip' ? first.promise : second.promise,
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = run();
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
expect(mocks.upload).toHaveBeenCalledTimes(2);
|
||||||
|
first.resolve({ id: 1 });
|
||||||
|
second.resolve({ id: 2 });
|
||||||
|
await result;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('waits for each upload when preserve_order is enabled', async () => {
|
||||||
|
config = {
|
||||||
|
...config,
|
||||||
|
input_files: ['one.zip', 'two.zip'],
|
||||||
|
input_preserve_order: true,
|
||||||
|
};
|
||||||
|
mocks.paths.mockReturnValue(['one.zip', 'two.zip']);
|
||||||
|
const first = deferred<{ id: number }>();
|
||||||
|
const second = deferred<{ id: number }>();
|
||||||
|
mocks.upload.mockImplementation((_config, _releaser, _url, path) =>
|
||||||
|
path === 'one.zip' ? first.promise : second.promise,
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = run();
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(mocks.upload).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
first.resolve({ id: 1 });
|
||||||
|
await vi.waitFor(() => expect(mocks.upload).toHaveBeenCalledTimes(2));
|
||||||
|
|
||||||
|
second.resolve({ id: 2 });
|
||||||
|
await result;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps an existing draft unpublished until its upload completes', async () => {
|
||||||
|
config = {
|
||||||
|
...config,
|
||||||
|
input_draft: false,
|
||||||
|
input_prerelease: true,
|
||||||
|
input_files: ['asset.zip'],
|
||||||
|
};
|
||||||
|
mocks.release.mockResolvedValue({ release: initialRelease, created: false });
|
||||||
|
mocks.paths.mockReturnValue(['asset.zip']);
|
||||||
|
const pendingUpload = deferred<{ id: number }>();
|
||||||
|
mocks.upload.mockReturnValue(pendingUpload.promise);
|
||||||
|
|
||||||
|
const result = run();
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
expect(mocks.upload).toHaveBeenCalledOnce();
|
||||||
|
expect(mocks.finalizeRelease).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
pendingUpload.resolve({ id: 7 });
|
||||||
|
await result;
|
||||||
|
|
||||||
|
expect(mocks.finalizeRelease).toHaveBeenCalledWith(
|
||||||
|
config,
|
||||||
|
mocks.releaser,
|
||||||
|
initialRelease,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the selected moving-tag release ID for upload, finalization, and outputs', async () => {
|
||||||
|
config = {
|
||||||
|
...config,
|
||||||
|
input_tag_name: 'nightly',
|
||||||
|
input_draft: false,
|
||||||
|
input_prerelease: true,
|
||||||
|
input_files: ['nightly.zip'],
|
||||||
|
};
|
||||||
|
const selectedRelease: Release = {
|
||||||
|
...initialRelease,
|
||||||
|
id: 77,
|
||||||
|
upload_url: 'https://uploads.example.test/releases/77/assets{?name,label}',
|
||||||
|
html_url: 'https://example.test/releases/77',
|
||||||
|
tag_name: 'nightly',
|
||||||
|
name: 'nightly',
|
||||||
|
prerelease: true,
|
||||||
|
};
|
||||||
|
const publishedRelease: Release = {
|
||||||
|
...selectedRelease,
|
||||||
|
draft: false,
|
||||||
|
};
|
||||||
|
mocks.release.mockResolvedValue({ release: selectedRelease, created: false });
|
||||||
|
mocks.paths.mockReturnValue(['nightly.zip']);
|
||||||
|
mocks.upload.mockResolvedValue({ id: 9 });
|
||||||
|
mocks.finalizeRelease.mockResolvedValue(publishedRelease);
|
||||||
|
mocks.listReleaseAssets.mockResolvedValue([{ id: 9, name: 'nightly.zip' }]);
|
||||||
|
|
||||||
|
await run();
|
||||||
|
|
||||||
|
expect(mocks.upload).toHaveBeenCalledWith(
|
||||||
|
config,
|
||||||
|
mocks.releaser,
|
||||||
|
'https://uploads.example.test/releases/77/assets',
|
||||||
|
'nightly.zip',
|
||||||
|
selectedRelease.assets,
|
||||||
|
77,
|
||||||
|
);
|
||||||
|
expect(mocks.finalizeRelease).toHaveBeenCalledWith(
|
||||||
|
config,
|
||||||
|
mocks.releaser,
|
||||||
|
selectedRelease,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
expect(mocks.listReleaseAssets).toHaveBeenCalledWith(config, mocks.releaser, publishedRelease);
|
||||||
|
expect(mocks.setOutput).toHaveBeenCalledWith('id', '77');
|
||||||
|
expect(mocks.setOutput).toHaveBeenCalledWith('url', publishedRelease.html_url);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves an existing draft recoverable when an upload fails', async () => {
|
||||||
|
config = {
|
||||||
|
...config,
|
||||||
|
input_draft: false,
|
||||||
|
input_prerelease: true,
|
||||||
|
input_files: ['asset.zip'],
|
||||||
|
};
|
||||||
|
mocks.release.mockResolvedValue({ release: initialRelease, created: false });
|
||||||
|
mocks.paths.mockReturnValue(['asset.zip']);
|
||||||
|
mocks.upload.mockRejectedValue(new Error('upload failed'));
|
||||||
|
|
||||||
|
await run();
|
||||||
|
|
||||||
|
expect(mocks.finalizeRelease).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.setFailed).toHaveBeenCalledWith('upload failed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('finalizes after uploads and outputs only newly uploaded assets without uploader data', async () => {
|
||||||
|
config = { ...config, input_files: ['one.zip', 'skipped.zip', 'two.zip'] };
|
||||||
|
mocks.paths.mockReturnValue(['one.zip', 'skipped.zip', 'two.zip']);
|
||||||
|
mocks.upload
|
||||||
|
.mockResolvedValueOnce({ id: 7 })
|
||||||
|
.mockResolvedValueOnce(null)
|
||||||
|
.mockResolvedValueOnce({ id: 9 });
|
||||||
|
mocks.listReleaseAssets.mockResolvedValue([
|
||||||
|
{ id: 1, name: 'existing.zip', uploader: { login: 'someone' } },
|
||||||
|
{ id: 7, name: 'one.zip', size: 10, uploader: { login: 'bot' } },
|
||||||
|
{ id: 9, name: 'two.zip', label: 'Two', uploader: null },
|
||||||
|
]);
|
||||||
|
|
||||||
|
await run();
|
||||||
|
|
||||||
|
expect(mocks.release.mock.invocationCallOrder[0]).toBeLessThan(
|
||||||
|
mocks.upload.mock.invocationCallOrder[0],
|
||||||
|
);
|
||||||
|
expect(mocks.upload.mock.invocationCallOrder[2]).toBeLessThan(
|
||||||
|
mocks.finalizeRelease.mock.invocationCallOrder[0],
|
||||||
|
);
|
||||||
|
expect(mocks.finalizeRelease).toHaveBeenCalledWith(
|
||||||
|
config,
|
||||||
|
mocks.releaser,
|
||||||
|
initialRelease,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(mocks.listReleaseAssets).toHaveBeenCalledWith(config, mocks.releaser, finalizedRelease);
|
||||||
|
expect(mocks.setOutput).toHaveBeenCalledWith('assets', [
|
||||||
|
{ id: 7, name: 'one.zip', size: 10 },
|
||||||
|
{ id: 9, name: 'two.zip', label: 'Two' },
|
||||||
|
]);
|
||||||
|
expect(mocks.setOutput).toHaveBeenCalledWith('url', finalizedRelease.html_url);
|
||||||
|
expect(mocks.setOutput).toHaveBeenCalledWith('id', '42');
|
||||||
|
expect(mocks.setOutput).toHaveBeenCalledWith('upload_url', finalizedRelease.upload_url);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('outputs an empty asset list and skips refresh when no upload returns an ID', async () => {
|
||||||
|
config = { ...config, input_files: ['skipped.zip'] };
|
||||||
|
mocks.paths.mockReturnValue(['skipped.zip']);
|
||||||
|
mocks.upload.mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
await run();
|
||||||
|
|
||||||
|
expect(mocks.listReleaseAssets).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.setOutput).toHaveBeenCalledWith('assets', []);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('configures rate-limit and abuse callbacks without making live requests', async () => {
|
||||||
|
await run();
|
||||||
|
|
||||||
|
const options = mocks.getOctokit.mock.calls[0][1];
|
||||||
|
const request = { method: 'GET', url: '/repos/owner/repo', request: { retryCount: 0 } };
|
||||||
|
expect(options.throttle.onRateLimit(5, request)).toBe(true);
|
||||||
|
expect(
|
||||||
|
options.throttle.onRateLimit(5, { ...request, request: { retryCount: 1 } }),
|
||||||
|
).toBeUndefined();
|
||||||
|
expect(options.throttle.onAbuseLimit(10, request)).toBeUndefined();
|
||||||
|
expect(console.warn).toHaveBeenCalledWith(
|
||||||
|
'Request quota exhausted for request GET /repos/owner/repo',
|
||||||
|
);
|
||||||
|
expect(console.warn).toHaveBeenCalledWith('Abuse detected for request GET /repos/owner/repo');
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[new Error('release failed'), 'release failed'],
|
||||||
|
['release failed', 'release failed'],
|
||||||
|
[{ message: 'release failed' }, 'release failed'],
|
||||||
|
[null, 'Unknown error'],
|
||||||
|
[undefined, 'Unknown error'],
|
||||||
|
])('normalizes a thrown value before reporting failure', async (thrown, expected) => {
|
||||||
|
mocks.parseConfig.mockImplementation(() => {
|
||||||
|
throw thrown;
|
||||||
|
});
|
||||||
|
|
||||||
|
await run();
|
||||||
|
|
||||||
|
expect(mocks.setFailed).toHaveBeenCalledWith(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,394 @@
|
|||||||
|
import { getOctokit } from '@actions/github';
|
||||||
|
import { createHash } from 'crypto';
|
||||||
|
import { createServer, type Server } from 'http';
|
||||||
|
import { type AddressInfo } from 'net';
|
||||||
|
import { mkdtemp, open, rm, writeFile } from 'fs/promises';
|
||||||
|
import { tmpdir } from 'os';
|
||||||
|
import { join } from 'path';
|
||||||
|
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { GitHubReleaser, upload } from '../src/github';
|
||||||
|
import type { Config } from '../src/util';
|
||||||
|
|
||||||
|
const openFile = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
|
vi.mock('fs/promises', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('fs/promises')>();
|
||||||
|
openFile.mockImplementation(actual.open);
|
||||||
|
return { ...actual, open: openFile };
|
||||||
|
});
|
||||||
|
|
||||||
|
type Fixture = {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
size: number;
|
||||||
|
digest: string;
|
||||||
|
contentType: string;
|
||||||
|
bytes?: Buffer;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Receipt = {
|
||||||
|
method: string | undefined;
|
||||||
|
pathname: string;
|
||||||
|
filename: string | null;
|
||||||
|
contentLength: string | undefined;
|
||||||
|
contentType: string | undefined;
|
||||||
|
size: number;
|
||||||
|
digest: string;
|
||||||
|
chunks: number;
|
||||||
|
chunkTypes: string[];
|
||||||
|
bytes?: Buffer;
|
||||||
|
};
|
||||||
|
|
||||||
|
const config: Config = {
|
||||||
|
github_token: 'not-a-real-token',
|
||||||
|
github_ref: 'refs/tags/v1.0.0',
|
||||||
|
github_repository: 'owner/repo',
|
||||||
|
input_files: [],
|
||||||
|
input_fail_on_unmatched_files: false,
|
||||||
|
input_generate_release_notes: false,
|
||||||
|
input_append_body: false,
|
||||||
|
input_make_latest: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
const sha256 = (data: Buffer): string => createHash('sha256').update(data).digest('hex');
|
||||||
|
|
||||||
|
const expectLastFileHandleClosed = async (): Promise<void> => {
|
||||||
|
const result = openFile.mock.results.at(-1);
|
||||||
|
expect(result?.type).toBe('return');
|
||||||
|
const fileHandle = await result?.value;
|
||||||
|
await expect(fileHandle.stat()).rejects.toMatchObject({ code: 'EBADF' });
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeServer = async (server: Server): Promise<void> => {
|
||||||
|
server.closeIdleConnections();
|
||||||
|
server.closeAllConnections();
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
server.close((error) => (error ? reject(error) : resolve()));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const startUploadServer = async (
|
||||||
|
responseStatus: (requestIndex: number) => number = () => 201,
|
||||||
|
): Promise<{ server: Server; uploadUrl: string; receipts: Receipt[] }> => {
|
||||||
|
const receipts: Receipt[] = [];
|
||||||
|
const server = createServer(async (request, response) => {
|
||||||
|
const url = new URL(request.url || '/', 'http://127.0.0.1');
|
||||||
|
const hash = createHash('sha256');
|
||||||
|
const bufferedChunks: Buffer[] = [];
|
||||||
|
const chunkTypes: string[] = [];
|
||||||
|
let size = 0;
|
||||||
|
let chunks = 0;
|
||||||
|
|
||||||
|
for await (const chunk of request) {
|
||||||
|
chunkTypes.push(chunk?.constructor?.name || typeof chunk);
|
||||||
|
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||||
|
chunks += 1;
|
||||||
|
size += bytes.length;
|
||||||
|
hash.update(bytes);
|
||||||
|
if (size <= 1024 * 1024) {
|
||||||
|
bufferedChunks.push(Buffer.from(bytes));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
receipts.push({
|
||||||
|
method: request.method,
|
||||||
|
pathname: url.pathname,
|
||||||
|
filename: url.searchParams.get('name'),
|
||||||
|
contentLength: request.headers['content-length'],
|
||||||
|
contentType: request.headers['content-type'],
|
||||||
|
size,
|
||||||
|
digest: hash.digest('hex'),
|
||||||
|
chunks,
|
||||||
|
chunkTypes,
|
||||||
|
bytes: size <= 1024 * 1024 ? Buffer.concat(bufferedChunks) : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const status = responseStatus(receipts.length - 1);
|
||||||
|
response.writeHead(status, { 'content-type': 'application/json' });
|
||||||
|
response.end(
|
||||||
|
JSON.stringify(
|
||||||
|
status === 201
|
||||||
|
? { id: 123, name: url.searchParams.get('name') }
|
||||||
|
: {
|
||||||
|
message: 'Validation Failed',
|
||||||
|
errors: [{ code: 'already_exists' }],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
server.once('error', reject);
|
||||||
|
server.listen(0, '127.0.0.1', resolve);
|
||||||
|
});
|
||||||
|
const address = server.address() as AddressInfo;
|
||||||
|
return {
|
||||||
|
server,
|
||||||
|
uploadUrl: `http://127.0.0.1:${address.port}/repos/owner/repo/releases/1/assets`,
|
||||||
|
receipts,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('release asset upload transport', () => {
|
||||||
|
let tempDirectory: string;
|
||||||
|
let fixtures: Fixture[];
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
tempDirectory = await mkdtemp(join(tmpdir(), 'action-gh-release-upload-'));
|
||||||
|
|
||||||
|
const smallFixtures = [
|
||||||
|
{
|
||||||
|
name: 'artifact.zip.sha512',
|
||||||
|
bytes: Buffer.from(`${'a'.repeat(128)}\n`),
|
||||||
|
contentType: 'application/octet-stream',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'artifact.md5',
|
||||||
|
bytes: Buffer.from(`${'b'.repeat(32)}\n`),
|
||||||
|
contentType: 'application/octet-stream',
|
||||||
|
},
|
||||||
|
{ name: 'one-byte.txt', bytes: Buffer.from([0x7f]), contentType: 'text/plain' },
|
||||||
|
{ name: 'empty.bin', bytes: Buffer.alloc(0), contentType: 'application/octet-stream' },
|
||||||
|
{
|
||||||
|
name: 'binary.bin',
|
||||||
|
bytes: Buffer.from([0x00, 0x01, 0x7f, 0x80, 0xfe, 0xff]),
|
||||||
|
contentType: 'application/octet-stream',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
fixtures = [];
|
||||||
|
for (const fixture of smallFixtures) {
|
||||||
|
const path = join(tempDirectory, fixture.name);
|
||||||
|
await writeFile(path, fixture.bytes);
|
||||||
|
fixtures.push({
|
||||||
|
...fixture,
|
||||||
|
path,
|
||||||
|
size: fixture.bytes.length,
|
||||||
|
digest: sha256(fixture.bytes),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const largePath = join(tempDirectory, 'large.bin');
|
||||||
|
const largeHandle = await open(largePath, 'w');
|
||||||
|
const largeHash = createHash('sha256');
|
||||||
|
const block = Buffer.alloc(64 * 1024);
|
||||||
|
for (let index = 0; index < block.length; index += 1) {
|
||||||
|
block[index] = index % 251;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
for (let index = 0; index < 128; index += 1) {
|
||||||
|
await largeHandle.write(block);
|
||||||
|
largeHash.update(block);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await largeHandle.close();
|
||||||
|
}
|
||||||
|
fixtures.push({
|
||||||
|
name: 'large.bin',
|
||||||
|
path: largePath,
|
||||||
|
size: 8 * 1024 * 1024,
|
||||||
|
digest: largeHash.digest('hex'),
|
||||||
|
contentType: 'application/octet-stream',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await rm(tempDirectory, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([0, 1, 2, 3, 4, 5])(
|
||||||
|
'uploads fixture %i through the real Octokit request path',
|
||||||
|
async (fixtureIndex) => {
|
||||||
|
const fixture = fixtures[fixtureIndex];
|
||||||
|
const { server, uploadUrl, receipts } = await startUploadServer();
|
||||||
|
const releaser = new GitHubReleaser(getOctokit(config.github_token));
|
||||||
|
openFile.mockClear();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await expect(upload(config, releaser, uploadUrl, fixture.path, [], 1)).resolves.toEqual({
|
||||||
|
id: 123,
|
||||||
|
name: fixture.name,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(receipts).toHaveLength(1);
|
||||||
|
expect(receipts[0]).toMatchObject({
|
||||||
|
method: 'POST',
|
||||||
|
pathname: '/repos/owner/repo/releases/1/assets',
|
||||||
|
filename: fixture.name,
|
||||||
|
contentLength: String(fixture.size),
|
||||||
|
contentType: fixture.contentType,
|
||||||
|
size: fixture.size,
|
||||||
|
digest: fixture.digest,
|
||||||
|
});
|
||||||
|
if (fixture.bytes) {
|
||||||
|
expect(receipts[0].bytes).toEqual(fixture.bytes);
|
||||||
|
}
|
||||||
|
expect(new Set(receipts[0].chunkTypes)).toEqual(
|
||||||
|
fixture.size === 0 ? new Set() : new Set(['Buffer']),
|
||||||
|
);
|
||||||
|
if (fixture.size > 1024 * 1024) {
|
||||||
|
expect(receipts[0].chunks).toBeGreaterThan(1);
|
||||||
|
}
|
||||||
|
await expectLastFileHandleClosed();
|
||||||
|
} finally {
|
||||||
|
await closeServer(server);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it('closes the file handle when the request fails', async () => {
|
||||||
|
const fixture = fixtures[0];
|
||||||
|
const { server, uploadUrl } = await startUploadServer(() => 500);
|
||||||
|
const releaser = new GitHubReleaser(getOctokit(config.github_token));
|
||||||
|
openFile.mockClear();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await expect(upload(config, releaser, uploadUrl, fixture.path, [], 1)).rejects.toThrow(
|
||||||
|
'Validation Failed',
|
||||||
|
);
|
||||||
|
await expectLastFileHandleClosed();
|
||||||
|
} finally {
|
||||||
|
await closeServer(server);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes ArrayBuffer chunks before they reach the Octokit transport', async () => {
|
||||||
|
const fixture = fixtures[0];
|
||||||
|
const { server, uploadUrl, receipts } = await startUploadServer();
|
||||||
|
const releaser = new GitHubReleaser(getOctokit(config.github_token));
|
||||||
|
const actual = await vi.importActual<typeof import('fs/promises')>('fs/promises');
|
||||||
|
openFile.mockImplementationOnce(async (...args: Parameters<typeof actual.open>) => {
|
||||||
|
const fileHandle = await actual.open(...args);
|
||||||
|
const readableWebStream = fileHandle.readableWebStream.bind(fileHandle);
|
||||||
|
Object.defineProperty(fileHandle, 'readableWebStream', {
|
||||||
|
configurable: true,
|
||||||
|
value: () =>
|
||||||
|
readableWebStream().pipeThrough(
|
||||||
|
new TransformStream<Uint8Array, ArrayBuffer>({
|
||||||
|
transform(chunk, controller) {
|
||||||
|
controller.enqueue(chunk.slice().buffer);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
return fileHandle;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await expect(upload(config, releaser, uploadUrl, fixture.path, [], 1)).resolves.toEqual({
|
||||||
|
id: 123,
|
||||||
|
name: fixture.name,
|
||||||
|
});
|
||||||
|
expect(receipts).toHaveLength(1);
|
||||||
|
expect(receipts[0]).toMatchObject({
|
||||||
|
size: fixture.size,
|
||||||
|
digest: fixture.digest,
|
||||||
|
});
|
||||||
|
await expectLastFileHandleClosed();
|
||||||
|
} finally {
|
||||||
|
await closeServer(server);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens a fresh upload body after an already-exists response', async () => {
|
||||||
|
const fixture = fixtures[0];
|
||||||
|
const { server, uploadUrl, receipts } = await startUploadServer((index) =>
|
||||||
|
index === 0 ? 422 : 201,
|
||||||
|
);
|
||||||
|
const releaser = new GitHubReleaser(getOctokit(config.github_token));
|
||||||
|
vi.spyOn(releaser, 'listReleaseAssets').mockResolvedValue([{ id: 9, name: fixture.name }]);
|
||||||
|
const deleteReleaseAsset = vi
|
||||||
|
.spyOn(releaser, 'deleteReleaseAsset')
|
||||||
|
.mockResolvedValue(undefined);
|
||||||
|
openFile.mockClear();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await expect(upload(config, releaser, uploadUrl, fixture.path, [], 1)).resolves.toEqual({
|
||||||
|
id: 123,
|
||||||
|
name: fixture.name,
|
||||||
|
});
|
||||||
|
expect(receipts).toHaveLength(2);
|
||||||
|
expect(receipts.map(({ size }) => size)).toEqual([fixture.size, fixture.size]);
|
||||||
|
expect(receipts.map(({ digest }) => digest)).toEqual([fixture.digest, fixture.digest]);
|
||||||
|
expect(openFile).toHaveBeenCalledTimes(2);
|
||||||
|
expect(deleteReleaseAsset).toHaveBeenCalledWith({
|
||||||
|
owner: 'owner',
|
||||||
|
repo: 'repo',
|
||||||
|
release_id: 1,
|
||||||
|
asset_id: 9,
|
||||||
|
});
|
||||||
|
for (const result of openFile.mock.results) {
|
||||||
|
const fileHandle = await result.value;
|
||||||
|
await expect(fileHandle.stat()).rejects.toMatchObject({ code: 'EBADF' });
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await closeServer(server);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the release-scoped delete route through the real Octokit request path', async () => {
|
||||||
|
const requests: Array<{
|
||||||
|
method: string | undefined;
|
||||||
|
pathname: string;
|
||||||
|
authorization: string | undefined;
|
||||||
|
}> = [];
|
||||||
|
const server = createServer((request, response) => {
|
||||||
|
const url = new URL(request.url || '/', 'http://127.0.0.1');
|
||||||
|
requests.push({
|
||||||
|
method: request.method,
|
||||||
|
pathname: url.pathname,
|
||||||
|
authorization: request.headers.authorization,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (url.pathname === '/repos/owner/repo/releases/assets/9') {
|
||||||
|
response.writeHead(404, { 'content-type': 'application/json' });
|
||||||
|
response.end(JSON.stringify({ message: 'page not found' }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (url.pathname === '/repos/owner/repo/releases/1/assets/9') {
|
||||||
|
response.writeHead(204);
|
||||||
|
response.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
response.writeHead(500, { 'content-type': 'application/json' });
|
||||||
|
response.end(JSON.stringify({ message: 'unexpected route' }));
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
server.once('error', reject);
|
||||||
|
server.listen(0, '127.0.0.1', resolve);
|
||||||
|
});
|
||||||
|
const address = server.address() as AddressInfo;
|
||||||
|
const releaser = new GitHubReleaser(
|
||||||
|
getOctokit(config.github_token, {
|
||||||
|
baseUrl: `http://127.0.0.1:${address.port}`,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await expect(
|
||||||
|
releaser.deleteReleaseAsset({
|
||||||
|
owner: 'owner',
|
||||||
|
repo: 'repo',
|
||||||
|
release_id: 1,
|
||||||
|
asset_id: 9,
|
||||||
|
}),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
expect(requests).toEqual([
|
||||||
|
{
|
||||||
|
method: 'DELETE',
|
||||||
|
pathname: '/repos/owner/repo/releases/assets/9',
|
||||||
|
authorization: 'token not-a-real-token',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'DELETE',
|
||||||
|
pathname: '/repos/owner/repo/releases/1/assets/9',
|
||||||
|
authorization: 'token not-a-real-token',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
await closeServer(server);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
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
@@ -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"
|
||||||
|
|||||||
Vendored
+31
-31
File diff suppressed because one or more lines are too long
Generated
+723
-580
File diff suppressed because it is too large
Load Diff
+14
-14
@@ -1,17 +1,17 @@
|
|||||||
{
|
{
|
||||||
"name": "action-gh-release",
|
"name": "action-gh-release",
|
||||||
"version": "2.6.0",
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+252
-89
@@ -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 }>;
|
||||||
|
|
||||||
@@ -71,6 +159,7 @@ export interface Releaser {
|
|||||||
repo: string;
|
repo: string;
|
||||||
release_id: number;
|
release_id: number;
|
||||||
make_latest: 'true' | 'false' | 'legacy' | undefined;
|
make_latest: 'true' | 'false' | 'legacy' | undefined;
|
||||||
|
discussion_category_name: string | undefined;
|
||||||
}): Promise<{ data: Release }>;
|
}): Promise<{ data: Release }>;
|
||||||
|
|
||||||
allReleases(params: { owner: string; repo: string }): AsyncIterable<{ data: Release[] }>;
|
allReleases(params: { owner: string; repo: string }): AsyncIterable<{ data: Release[] }>;
|
||||||
@@ -81,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>;
|
||||||
|
|
||||||
@@ -98,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 }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,6 +275,7 @@ export class GitHubReleaser implements Releaser {
|
|||||||
repo: string;
|
repo: string;
|
||||||
release_id: number;
|
release_id: number;
|
||||||
make_latest: 'true' | 'false' | 'legacy' | undefined;
|
make_latest: 'true' | 'false' | 'legacy' | undefined;
|
||||||
|
discussion_category_name: string | undefined;
|
||||||
}) {
|
}) {
|
||||||
return await this.github.rest.repos.updateRelease({
|
return await this.github.rest.repos.updateRelease({
|
||||||
owner: params.owner,
|
owner: params.owner,
|
||||||
@@ -188,6 +283,7 @@ export class GitHubReleaser implements Releaser {
|
|||||||
release_id: params.release_id,
|
release_id: params.release_id,
|
||||||
draft: false,
|
draft: false,
|
||||||
make_latest: params.make_latest,
|
make_latest: params.make_latest,
|
||||||
|
discussion_category_name: params.discussion_category_name,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,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> {
|
||||||
@@ -236,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',
|
||||||
@@ -268,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));
|
||||||
@@ -279,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 = (
|
||||||
@@ -304,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.
|
||||||
@@ -324,6 +441,7 @@ export const upload = async (
|
|||||||
asset_id: currentAsset.id || 1,
|
asset_id: currentAsset.id || 1,
|
||||||
owner,
|
owner,
|
||||||
repo,
|
repo,
|
||||||
|
release_id: releaseId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -364,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();
|
||||||
@@ -396,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 {
|
||||||
@@ -434,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));
|
||||||
@@ -464,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(
|
||||||
@@ -482,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());
|
||||||
@@ -514,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})`);
|
||||||
|
|
||||||
@@ -588,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}`,
|
||||||
);
|
);
|
||||||
@@ -641,6 +775,7 @@ export const finalizeRelease = async (
|
|||||||
repo,
|
repo,
|
||||||
release_id: release.id,
|
release_id: release.id,
|
||||||
make_latest: config.input_make_latest,
|
make_latest: config.input_make_latest,
|
||||||
|
discussion_category_name: config.input_discussion_category_name,
|
||||||
});
|
});
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
@@ -715,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(
|
||||||
@@ -730,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;
|
||||||
@@ -793,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}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -836,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}`);
|
||||||
}
|
}
|
||||||
@@ -856,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -929,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',
|
||||||
@@ -942,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...',
|
||||||
);
|
);
|
||||||
@@ -965,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
@@ -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
@@ -0,0 +1,104 @@
|
|||||||
|
import { setFailed, setOutput } from '@actions/core';
|
||||||
|
import { getOctokit } from '@actions/github';
|
||||||
|
import { env } from 'process';
|
||||||
|
import { GitHubReleaser, release, finalizeRelease, upload, listReleaseAssets } from './github';
|
||||||
|
import { errorMessage, isTag, parseConfig, paths, unmatchedPatterns, uploadUrl } from './util';
|
||||||
|
|
||||||
|
export async function run(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const config = parseConfig(env);
|
||||||
|
if (!config.input_tag_name && !isTag(config.github_ref) && !config.input_draft) {
|
||||||
|
throw new Error(`⚠️ GitHub Releases requires a tag`);
|
||||||
|
}
|
||||||
|
if (config.input_files) {
|
||||||
|
const patterns = unmatchedPatterns(config.input_files, config.input_working_directory);
|
||||||
|
patterns.forEach((pattern) => {
|
||||||
|
if (config.input_fail_on_unmatched_files) {
|
||||||
|
throw new Error(`⚠️ Pattern '${pattern}' does not match any files.`);
|
||||||
|
} else {
|
||||||
|
console.warn(`🤔 Pattern '${pattern}' does not match any files.`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const gh = getOctokit(config.github_token, {
|
||||||
|
throttle: {
|
||||||
|
onRateLimit: (retryAfter, options) => {
|
||||||
|
console.warn(`Request quota exhausted for request ${options.method} ${options.url}`);
|
||||||
|
if (options.request.retryCount === 0) {
|
||||||
|
console.log(`Retrying after ${retryAfter} seconds!`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onAbuseLimit: (_retryAfter, options) => {
|
||||||
|
console.warn(`Abuse detected for request ${options.method} ${options.url}`);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const releaser = new GitHubReleaser(gh);
|
||||||
|
const releaseResult = await release(config, releaser);
|
||||||
|
let rel = releaseResult.release;
|
||||||
|
const releaseWasCreated = releaseResult.created;
|
||||||
|
let uploadedAssetIds: Set<number> = new Set();
|
||||||
|
if (config.input_files && config.input_files.length > 0) {
|
||||||
|
const files = paths(config.input_files, config.input_working_directory);
|
||||||
|
if (files.length === 0) {
|
||||||
|
if (config.input_fail_on_unmatched_files) {
|
||||||
|
throw new Error(`⚠️ ${config.input_files} does not include a valid file.`);
|
||||||
|
} else {
|
||||||
|
console.warn(`🤔 ${config.input_files} does not include a valid file.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const currentAssets = rel.assets;
|
||||||
|
|
||||||
|
const uploadFile = async (path: string) => {
|
||||||
|
const json = await upload(
|
||||||
|
config,
|
||||||
|
releaser,
|
||||||
|
uploadUrl(rel.upload_url),
|
||||||
|
path,
|
||||||
|
currentAssets,
|
||||||
|
rel.id,
|
||||||
|
);
|
||||||
|
return json ? (json.id as number) : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
let results: (number | undefined)[];
|
||||||
|
if (!config.input_preserve_order) {
|
||||||
|
results = await Promise.all(files.map(uploadFile));
|
||||||
|
} else {
|
||||||
|
results = [];
|
||||||
|
for (const path of files) {
|
||||||
|
results.push(await uploadFile(path));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
uploadedAssetIds = new Set(results.filter((id): id is number => id !== undefined));
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Finalizing release...');
|
||||||
|
rel = await finalizeRelease(config, releaser, rel, releaseWasCreated);
|
||||||
|
|
||||||
|
// Draft releases use temporary "untagged-..." URLs for assets.
|
||||||
|
// URLs will be changed to correct ones once the release is published.
|
||||||
|
console.log('Getting assets list...');
|
||||||
|
let assets: any[] = [];
|
||||||
|
if (uploadedAssetIds.size > 0) {
|
||||||
|
const updatedAssets = await listReleaseAssets(config, releaser, rel);
|
||||||
|
assets = updatedAssets
|
||||||
|
.filter((asset) => uploadedAssetIds.has(asset.id))
|
||||||
|
.map((asset) => {
|
||||||
|
const { uploader, ...rest } = asset;
|
||||||
|
return rest;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setOutput('assets', assets);
|
||||||
|
|
||||||
|
console.log(`🎉 Release ready at ${rel.html_url}`);
|
||||||
|
setOutput('url', rel.html_url);
|
||||||
|
setOutput('id', rel.id.toString());
|
||||||
|
setOutput('upload_url', rel.upload_url);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
setFailed(errorMessage(error));
|
||||||
|
}
|
||||||
|
}
|
||||||
+18
@@ -28,6 +28,24 @@ export interface Config {
|
|||||||
input_make_latest: 'true' | 'false' | 'legacy' | undefined;
|
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
@@ -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
@@ -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'],
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user