mirror of
https://gh-proxy.org/https://github.com/softprops/action-gh-release.git
synced 2026-07-15 23:13:02 +08:00
Compare commits
58 Commits
v2.5.2
...
7e13ed4ac5
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 26e8ad27a0 | |||
| b959f31e96 | |||
| 8a8510e3a0 | |||
| 438c15ddf5 | |||
| 6ca3b5d96e | |||
| 11f917660b | |||
| 1f3f350167 | |||
| 37819cb191 | |||
| 9312864490 | |||
| 1853d73993 | |||
| e8dbf3cc4a | |||
| 37f7a20824 | |||
| 45211baa90 | |||
| 21ae1a1eb2 | |||
| 26c9a934b1 | |||
| abb4370aef | |||
| ff689a6881 | |||
| 0a28836784 | |||
| bafaa2d7ac | |||
| b36466e122 |
@@ -1 +0,0 @@
|
||||
ko_fi: softprops
|
||||
@@ -14,7 +14,9 @@ updates:
|
||||
- ">=3.0.0"
|
||||
- dependency-name: "@types/node"
|
||||
versions:
|
||||
- ">=22.0.0"
|
||||
- ">=25.0.0"
|
||||
cooldown:
|
||||
default-days: 7
|
||||
commit-message:
|
||||
prefix: "chore(deps)"
|
||||
- package-ecosystem: github-actions
|
||||
@@ -25,5 +27,7 @@ updates:
|
||||
github-actions:
|
||||
patterns:
|
||||
- "*"
|
||||
cooldown:
|
||||
default-days: 7
|
||||
commit-message:
|
||||
prefix: "chore(deps)"
|
||||
|
||||
@@ -4,29 +4,73 @@ on:
|
||||
push:
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: ".tool-versions"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install
|
||||
run: npm ci
|
||||
- name: Typecheck
|
||||
run: npm run typecheck
|
||||
- name: Build
|
||||
run: npm run build
|
||||
- name: Check dist freshness
|
||||
id: dist_freshness
|
||||
continue-on-error: true
|
||||
run: |
|
||||
git diff --exit-code --stat -- dist/index.js \
|
||||
|| (echo "##[error] found changed dist/index.js after build. please run 'npm run build' and commit the updated bundle" \
|
||||
&& exit 1)
|
||||
- name: Comment on stale dist bundle
|
||||
if: github.event_name == 'pull_request' && steps.dist_freshness.outcome == 'failure'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
run: |
|
||||
marker='<!-- dist-freshness-check -->'
|
||||
diff_stat="$(git diff --stat -- dist/index.js)"
|
||||
body=$(cat <<EOF
|
||||
$marker
|
||||
\`dist/index.js\` changed after \`npm run build\`.
|
||||
|
||||
Please run \`npm run build\` and commit the regenerated bundle.
|
||||
|
||||
\`\`\`text
|
||||
$diff_stat
|
||||
\`\`\`
|
||||
EOF
|
||||
)
|
||||
|
||||
existing_id="$(
|
||||
gh api "repos/$GH_REPO/issues/$PR_NUMBER/comments" \
|
||||
--jq '.[] | select(.user.login == "github-actions[bot]" and (.body | contains("<!-- dist-freshness-check -->"))) | .id' \
|
||||
| tail -n 1
|
||||
)"
|
||||
|
||||
if [ -n "$existing_id" ]; then
|
||||
gh api --method PATCH "repos/$GH_REPO/issues/comments/$existing_id" -f body="$body" >/dev/null
|
||||
else
|
||||
gh api --method POST "repos/$GH_REPO/issues/$PR_NUMBER/comments" -f body="$body" >/dev/null
|
||||
fi
|
||||
- name: Fail on stale dist bundle
|
||||
if: steps.dist_freshness.outcome == 'failure'
|
||||
run: |
|
||||
echo "##[error] found changed dist/index.js after build. please run 'npm run build' and commit the updated bundle"
|
||||
exit 1
|
||||
- name: Test
|
||||
run: npm run test
|
||||
- name: Format
|
||||
run: npm run fmtcheck
|
||||
# - name: "check for uncommitted changes"
|
||||
# # Ensure no changes, but ignore node_modules dir since dev/fresh ci deps installed.
|
||||
# run: |
|
||||
# git diff --exit-code --stat -- . ':!node_modules' \
|
||||
# || (echo "##[error] found changed files after build. please 'npm run build && npm run fmt'" \
|
||||
# "and check in all changes" \
|
||||
# && exit 1)
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
nodejs 24.11.0
|
||||
nodejs 24.18.0
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
# action-gh-release
|
||||
|
||||
This repository is maintained as a small, user-facing GitHub Action with a relatively wide compatibility surface.
|
||||
Optimize for stability, reproducibility, and clear user value over broad rewrites.
|
||||
|
||||
## Core Rules
|
||||
|
||||
- Prefer narrow behavior fixes over structural churn.
|
||||
- Reproduce current behavior on `master` before changing code.
|
||||
- Treat GitHub platform behavior as distinct from action behavior.
|
||||
If GitHub controls the outcome, prefer docs or clearer errors over brittle workarounds.
|
||||
- Do not revive stale PRs mechanically.
|
||||
Reuse the idea if it still has value, but reimplement on top of current `master`.
|
||||
- Avoid standalone refactors with no clear user-facing benefit.
|
||||
|
||||
## Current Architecture
|
||||
|
||||
- `src/main.ts` is the action bootstrap; `src/run.ts` orchestrates config parsing, validation, release creation/update, asset uploads, finalization, and outputs.
|
||||
- `src/github.ts` owns release semantics: lookup, create/update/finalize, asset upload, race handling, and GitHub API interaction.
|
||||
- `src/util.ts` owns parsing and path normalization.
|
||||
- Keep behavior-specific logic in `src/github.ts` or `src/util.ts`; avoid growing `src/main.ts` with ad-hoc feature branches.
|
||||
|
||||
## Bug-Fix Workflow
|
||||
|
||||
- Reproduce the issue against current `master` first.
|
||||
- When available, use the companion consumer harness repo `action-gh-release-test`.
|
||||
- Capture exact workflow run URLs and release URLs before claiming a fix.
|
||||
- If the issue is really a docs/usage or platform-limit case, document it and close it as such instead of forcing a code change.
|
||||
- If a historical issue no longer reproduces on current `master`, prefer a short closeout note that asks the reporter to open a fresh issue if they still see it.
|
||||
|
||||
## Feature Triage
|
||||
|
||||
- Ship features only when there is clear user value or repeated demand.
|
||||
- Small convenience features are fine, but they should stay small.
|
||||
- Weak-demand features should not expand parsing complexity, cross-platform ambiguity, or maintenance surface.
|
||||
- For old feature PRs:
|
||||
- check whether current `master` already covers the behavior
|
||||
- prefer a tiny docs clarification if the behavior exists but is poorly explained
|
||||
- close stale feature PRs when the idea is obsolete, low-value, or badly shaped for the current codebase
|
||||
|
||||
## Contract Sync
|
||||
|
||||
When behavior changes, keep the external contract in sync:
|
||||
|
||||
- update `README.md`
|
||||
- update `action.yml`
|
||||
- update tests under `__tests__/`
|
||||
- regenerate `dist/index.js` with `npm run build`
|
||||
|
||||
Docs-only changes do not need `dist/index.js` regeneration.
|
||||
|
||||
## Verification
|
||||
|
||||
For code changes, run:
|
||||
|
||||
- `npm run fmtcheck`
|
||||
- `npm run typecheck`
|
||||
- `npm run build`
|
||||
- `npm test`
|
||||
|
||||
For behavior changes, also run the relevant external regression workflow(s) in `action-gh-release-test` against the exact ref under test.
|
||||
|
||||
## Release and Triage Conventions
|
||||
|
||||
- Keep PR labels accurate. Release notes depend on them.
|
||||
- bug fixes: `bug`
|
||||
- docs-only changes: `documentation`
|
||||
- additive features: `feature` or `enhancement`
|
||||
- dependency updates: `dependencies`
|
||||
- Follow [RELEASE.md](RELEASE.md) for version bumps, changelog updates, tagging, and release publication.
|
||||
- Prefer manual issue/PR closeouts with a short rationale over implicit assumptions.
|
||||
- Do not auto-close old PRs or issues through unrelated docs PRs.
|
||||
|
||||
## Implementation Preferences
|
||||
|
||||
- Preserve the current upload/finalize flow unless there is strong evidence it needs to change.
|
||||
- Prefer upload-time semantics over filesystem mutation.
|
||||
- Be careful with parsing changes around `files`, path handling, and Windows compatibility.
|
||||
- Be careful with race-condition fixes; verify both local tests and consumer-repo concurrency harnesses.
|
||||
- Do not assume a refactor is safe just because tests are green. This action’s behavior is heavily shaped by GitHub API edge cases.
|
||||
@@ -1,3 +1,98 @@
|
||||
## 3.0.1
|
||||
|
||||
- maintenance release with updated dependencies
|
||||
|
||||
## 3.0.0
|
||||
|
||||
`3.0.0` is a major release that moves the action runtime from Node 20 to Node 24.
|
||||
Use `v3` on GitHub-hosted runners and self-hosted fleets that already support the
|
||||
Node 24 Actions runtime. If you still need the last Node 20-compatible line, stay on
|
||||
`v2.6.2`.
|
||||
|
||||
## What's Changed
|
||||
|
||||
### Other Changes 🔄
|
||||
|
||||
* Move the action runtime and bundle target to Node 24
|
||||
* Update `@types/node` to the Node 24 line and allow future Dependabot updates
|
||||
* Keep the floating major tag on `v3`; `v2` remains pinned to the latest `2.x` release
|
||||
|
||||
## 2.6.2
|
||||
|
||||
## What's Changed
|
||||
|
||||
### 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` is a minor release centered on `previous_tag` support for `generate_release_notes`,
|
||||
which lets workflows pin GitHub's comparison base explicitly instead of relying on the default range.
|
||||
It also includes the recent concurrent asset upload recovery fix, a `working_directory` docs sync,
|
||||
a checked-bundle freshness guard for maintainers, and clearer immutable-prerelease guidance where
|
||||
GitHub platform behavior imposes constraints on how prerelease asset uploads can be published.
|
||||
|
||||
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
|
||||
|
||||
### Exciting New Features 🎉
|
||||
|
||||
* feat: support previous_tag for generate_release_notes by @pocesar in https://github.com/softprops/action-gh-release/pull/372
|
||||
|
||||
### Bug fixes 🐛
|
||||
|
||||
* fix: recover concurrent asset metadata 404s by @chenrui333 in https://github.com/softprops/action-gh-release/pull/760
|
||||
|
||||
### Other Changes 🔄
|
||||
|
||||
* docs: clarify reused draft release behavior by @chenrui333 in https://github.com/softprops/action-gh-release/pull/759
|
||||
* docs: clarify working_directory input by @chenrui333 in https://github.com/softprops/action-gh-release/pull/761
|
||||
* ci: verify dist bundle freshness by @chenrui333 in https://github.com/softprops/action-gh-release/pull/762
|
||||
* fix: clarify immutable prerelease uploads by @chenrui333 in https://github.com/softprops/action-gh-release/pull/763
|
||||
|
||||
## 2.5.3
|
||||
|
||||
`2.5.3` is a patch release focused on the remaining path-handling and release-selection bugs uncovered after `2.5.2`.
|
||||
It fixes `#639`, `#571`, `#280`, `#614`, `#311`, `#403`, and `#368`.
|
||||
It also adds documentation clarifications for `#541`, `#645`, `#542`, `#393`, and `#411`,
|
||||
where the current behavior is either usage-sensitive or constrained by GitHub platform limits rather than an action-side runtime bug.
|
||||
|
||||
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: prefer token input over GITHUB_TOKEN by @chenrui333 in https://github.com/softprops/action-gh-release/pull/751
|
||||
* fix: clean up duplicate drafts after canonicalization by @chenrui333 in https://github.com/softprops/action-gh-release/pull/753
|
||||
* fix: support Windows-style file globs by @chenrui333 in https://github.com/softprops/action-gh-release/pull/754
|
||||
* fix: normalize refs-tag inputs by @chenrui333 in https://github.com/softprops/action-gh-release/pull/755
|
||||
* fix: expand tilde file paths by @chenrui333 in https://github.com/softprops/action-gh-release/pull/756
|
||||
|
||||
### Other Changes 🔄
|
||||
|
||||
* docs: clarify token precedence by @chenrui333 in https://github.com/softprops/action-gh-release/pull/752
|
||||
* docs: clarify GitHub release limits by @chenrui333 in https://github.com/softprops/action-gh-release/pull/758
|
||||
* documentation clarifications for empty-token handling, `preserve_order`, and special-character asset filename behavior
|
||||
|
||||
## 2.5.2
|
||||
|
||||
`2.5.2` is a patch release focused on the remaining release-creation and prerelease regressions in the `2.5.x` bug-fix cycle.
|
||||
|
||||
@@ -39,6 +39,9 @@ Typically usage of this action involves adding a step to a build that
|
||||
is gated pushes to git tags. You may find `step.if` field helpful in accomplishing this
|
||||
as it maximizes the reuse value of your workflow for non-tag pushes.
|
||||
|
||||
`v3` requires a GitHub Actions runtime that supports Node 24. If you still need the
|
||||
last Node 20-compatible line, stay on `v2.6.2`.
|
||||
|
||||
Below is a simple example of `step.if` tag gating
|
||||
|
||||
```yaml
|
||||
@@ -53,7 +56,7 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
- name: Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
uses: softprops/action-gh-release@v3
|
||||
if: github.ref_type == 'tag'
|
||||
```
|
||||
|
||||
@@ -74,7 +77,7 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
- name: Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
uses: softprops/action-gh-release@v3
|
||||
```
|
||||
|
||||
### ⬆️ Uploading release assets
|
||||
@@ -105,7 +108,7 @@ jobs:
|
||||
- name: Test
|
||||
run: cat Release.txt
|
||||
- name: Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
uses: softprops/action-gh-release@v3
|
||||
if: github.ref_type == 'tag'
|
||||
with:
|
||||
files: Release.txt
|
||||
@@ -129,7 +132,7 @@ jobs:
|
||||
- name: Test
|
||||
run: cat Release.txt
|
||||
- name: Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
uses: softprops/action-gh-release@v3
|
||||
if: github.ref_type == 'tag'
|
||||
with:
|
||||
files: |
|
||||
@@ -139,7 +142,21 @@ jobs:
|
||||
|
||||
> **⚠️ Note:** Notice the `|` in the yaml syntax above ☝️. That lets you effectively declare a multi-line yaml string. You can learn more about multi-line yaml syntax [here](https://yaml-multiline.info)
|
||||
|
||||
> **⚠️ Note for Windows:** Paths must use `/` as a separator, not `\`, as `\` is used to escape characters with special meaning in the pattern; for example, instead of specifying `D:\Foo.txt`, you must specify `D:/Foo.txt`. If you're using PowerShell, you can do this with `$Path = $Path -replace '\\','/'`
|
||||
> **⚠️ Note for Windows:** Both `\` and `/` path separators are accepted in `files` globs. If you need to match a literal glob metacharacter such as `[` or `]`, keep escaping the metacharacter itself in the pattern.
|
||||
|
||||
If your release assets are generated under a subdirectory, set `working_directory`
|
||||
and keep the `files` patterns relative to that directory.
|
||||
|
||||
```yaml
|
||||
- name: Release
|
||||
uses: softprops/action-gh-release@v3
|
||||
if: github.ref_type == 'tag'
|
||||
with:
|
||||
working_directory: dist
|
||||
files: |
|
||||
Release.txt
|
||||
checksums/*.txt
|
||||
```
|
||||
|
||||
### 📝 External release notes
|
||||
|
||||
@@ -161,16 +178,32 @@ jobs:
|
||||
- name: Generate Changelog
|
||||
run: echo "# Good things have arrived" > ${{ github.workspace }}-CHANGELOG.txt
|
||||
- name: Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
uses: softprops/action-gh-release@v3
|
||||
if: github.ref_type == 'tag'
|
||||
with:
|
||||
body_path: ${{ github.workspace }}-CHANGELOG.txt
|
||||
repository: my_gh_org/my_gh_repo
|
||||
# note you'll typically need to create a personal access token
|
||||
# with permissions to create releases in the other repo
|
||||
# with permissions to create releases in the other repo.
|
||||
# A non-empty explicit token overrides GITHUB_TOKEN.
|
||||
# Omit the input to use github.token; passing "" treats the token as unset.
|
||||
token: ${{ secrets.CUSTOM_GITHUB_TOKEN }}
|
||||
```
|
||||
|
||||
When you use GitHub's built-in `generate_release_notes` support, you can optionally
|
||||
pin the comparison base explicitly with `previous_tag`. This is useful when the default
|
||||
comparison range does not match the release series you want to publish.
|
||||
|
||||
```yaml
|
||||
- name: Release
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
tag_name: stage-2026-03-15
|
||||
target_commitish: ${{ github.sha }}
|
||||
previous_tag: prod-2026-03-01
|
||||
generate_release_notes: true
|
||||
```
|
||||
|
||||
### 💅 Customizing
|
||||
|
||||
#### inputs
|
||||
@@ -181,28 +214,48 @@ The following are optional as `step.with` keys
|
||||
| -------------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `body` | String | Text communicating notable changes in this release |
|
||||
| `body_path` | String | Path to load text communicating notable changes in this release |
|
||||
| `draft` | Boolean | Indicator of whether or not this release is a draft |
|
||||
| `draft` | Boolean | Keep the release as a draft. Defaults to false. When reusing an existing draft release, set this to true to keep it draft; omit it to publish after upload. |
|
||||
| `prerelease` | Boolean | Indicator of whether or not is a prerelease |
|
||||
| `preserve_order` | Boolean | Indicator of whether order of files should be preserved when uploading assets |
|
||||
| `files` | String | Newline-delimited globs of paths to assets to upload for release |
|
||||
| `preserve_order` | Boolean | Upload assets sequentially in the provided order. This controls the action's upload behavior, but it does not control the final asset ordering that GitHub may display on the release page or return from the Releases API. |
|
||||
| `files` | String | Newline-delimited globs of paths to assets to upload for release. Escape glob metacharacters when you need to match a literal filename that contains them, such as `[` or `]`. `~/...` expands to the runner home directory. On Windows, both `\` and `/` separators are accepted. GitHub may normalize raw asset filenames that contain special characters; the action restores the asset label when possible, but the final download name remains GitHub-controlled. |
|
||||
| `working_directory` | String | Base directory to resolve `files` globs against. Use this when release assets live under a subdirectory. If omitted, the action resolves `files` from `${{ github.workspace }}`. |
|
||||
| `overwrite_files` | Boolean | Indicator of whether files should be overwritten when they already exist. Defaults to true |
|
||||
| `name` | String | Name of the release. defaults to tag name |
|
||||
| `tag_name` | String | Name of a tag. defaults to `github.ref_name` |
|
||||
| `tag_name` | String | Name of a tag. defaults to `github.ref_name`. `refs/tags/<name>` values are normalized to `<name>`. |
|
||||
| `fail_on_unmatched_files` | Boolean | Indicator of whether to fail if any of the `files` globs match nothing |
|
||||
| `repository` | String | Name of a target repository in `<owner>/<repo>` format. Defaults to GITHUB_REPOSITORY env variable |
|
||||
| `target_commitish` | String | Commitish value that determines where the Git tag is created from. Can be any branch or commit SHA. Defaults to repository default branch. |
|
||||
| `token` | String | Secret GitHub Personal Access Token. Defaults to `${{ github.token }}` |
|
||||
| `target_commitish` | String | Commitish value that determines where the Git tag is created from. Can be any branch or commit SHA. Defaults to repository default branch. When creating a new tag for an older commit, `github.token` may not have permission to create the ref; use a PAT or another token with sufficient contents permissions if you hit `403 Resource not accessible by integration`. |
|
||||
| `token` | String | Authorized GitHub token or PAT. Defaults to `${{ github.token }}` when omitted. A non-empty explicit token overrides `GITHUB_TOKEN`. Passing `""` treats the token as explicitly unset, so omit the input entirely or use an expression such as `${{ inputs.token || github.token }}` when wrapping this action in a composite action. |
|
||||
| `discussion_category_name` | String | If specified, a discussion of the specified category is created and linked to the release. The value must be a category that already exists in the repository. For more information, see ["Managing categories for discussions in your repository."](https://docs.github.com/en/discussions/managing-discussions-for-your-community/managing-categories-for-discussions-in-your-repository) |
|
||||
| `generate_release_notes` | Boolean | Whether to automatically generate the name and body for this release. If name is specified, the specified name will be used; otherwise, a name will be automatically generated. If body is specified, the body will be pre-pended to the automatically generated notes. See the [GitHub docs for this feature](https://docs.github.com/en/repositories/releasing-projects-on-github/automatically-generated-release-notes) for more information |
|
||||
| `previous_tag` | String | Optional. When `generate_release_notes` is enabled, use this tag as GitHub's `previous_tag_name` comparison base. If omitted, GitHub chooses the comparison base automatically. |
|
||||
| `append_body` | Boolean | Append to existing body instead of overwriting it |
|
||||
| `make_latest` | String | Specifies whether this release should be set as the latest release for the repository. Drafts and prereleases cannot be set as latest. Can be `true`, `false`, or `legacy`. Uses GitHub api defaults if not provided |
|
||||
|
||||
💡 When providing a `body` and `body_path` at the same time, `body_path` will be
|
||||
attempted first, then falling back on `body` if the path can not be read from.
|
||||
|
||||
💡 When the release info keys (such as `name`, `body`, `draft`, `prerelease`, etc.)
|
||||
are not explicitly set and there is already an existing release for the tag, the
|
||||
release will retain its original info.
|
||||
💡 When the release info keys (such as `name`, `body`, `prerelease`, etc.) are not
|
||||
explicitly set and there is already an existing release for the tag, the release
|
||||
will retain its original info.
|
||||
|
||||
💡 Draft status is handled separately during finalization. If the action reuses an
|
||||
existing draft release, set `draft: true` to keep it draft; if `draft` is omitted,
|
||||
the action will publish that draft after uploading assets.
|
||||
|
||||
💡 GitHub immutable releases lock assets after publication. Standard releases in this
|
||||
action already upload assets before publishing, but prereleases stay published by
|
||||
default so `release.prereleased` workflows keep firing. On an immutable-release
|
||||
repository, use `draft: true` for prereleases that upload assets, then publish that
|
||||
draft later and subscribe downstream workflows to `release.published`.
|
||||
|
||||
💡 `files` is glob-based, so literal filenames that contain glob metacharacters such as
|
||||
`[` or `]` must be escaped in the pattern.
|
||||
|
||||
💡 GitHub may normalize or rewrite uploaded asset filenames that contain special or
|
||||
non-ASCII characters. This action uploads the requested file, but it cannot force the
|
||||
final asset name that GitHub stores or returns from the Releases API. In particular,
|
||||
4-byte Unicode characters such as emoji cannot currently be restored via asset labels.
|
||||
|
||||
#### outputs
|
||||
|
||||
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
# Release Workflow
|
||||
|
||||
Use this checklist when cutting a new `action-gh-release` release.
|
||||
|
||||
## Inputs
|
||||
|
||||
- Decide the semantic version bump first: `major`, `minor`, or `patch`.
|
||||
- Review recent merged PRs and labels before drafting the changelog entry.
|
||||
- Make sure `master` is current and the worktree is clean before starting.
|
||||
|
||||
## Checklist
|
||||
|
||||
1. Update [package.json](package.json) to the new version.
|
||||
2. Add the new entry at the top of [CHANGELOG.md](CHANGELOG.md).
|
||||
- Summarize the release in 1 short paragraph.
|
||||
- If the summary mentions issues, use plain `#123` references or full issue links; do not wrap issue numbers like `#123` in backticks.
|
||||
- Prefer user-facing fixes and features over internal churn.
|
||||
- Keep the merged PR list aligned with `.github/release.yml` categories.
|
||||
3. Run `npm i` to refresh [package-lock.json](package-lock.json).
|
||||
4. Run the full local verification set:
|
||||
- `npm run fmtcheck`
|
||||
- `npm run typecheck`
|
||||
- `npm run build`
|
||||
- `npm test`
|
||||
5. Commit the release prep.
|
||||
- Use a plain release commit message like `release 3.0.0`.
|
||||
6. Create the annotated tag for the release commit.
|
||||
- Example: `git tag -a v3.0.0 -m "v3.0.0"`
|
||||
7. Push the commit and tag.
|
||||
- Example: `git push origin master && git push origin v3.0.0`
|
||||
8. Move the floating major tag to the new release tag.
|
||||
- For the current major line, run `npm run updatetag` to move `v3`.
|
||||
- Keep `v2` pinned to the latest `2.x` release for consumers that still need the Node 20 runtime.
|
||||
- Verify the floating tag points at the same commit as the new full tag.
|
||||
9. Create the GitHub release from the new tag.
|
||||
- Prefer the release body from [CHANGELOG.md](CHANGELOG.md), then let GitHub append generated notes only if they add value.
|
||||
- Verify the release shows the expected tag, title, notes, and attached artifacts.
|
||||
|
||||
## Notes
|
||||
|
||||
- Behavior changes should already have matching updates in [README.md](README.md), [action.yml](action.yml), tests, and `dist/index.js` before release prep begins.
|
||||
- Docs-only releases still need an intentional changelog entry and version bump decision.
|
||||
- If a release is mainly bug fixes, keep the title and summary patch-oriented; do not bury the actual fixes under dependency noise.
|
||||
+1396
-17
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,385 @@
|
||||
import type { Config } from '../src/util';
|
||||
import type { Release } from '../src/github';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
setFailed: vi.fn(),
|
||||
setOutput: vi.fn(),
|
||||
getOctokit: vi.fn(),
|
||||
GitHubReleaser: vi.fn(function () {
|
||||
return mocks.releaser;
|
||||
}),
|
||||
releaser: { name: 'releaser' },
|
||||
release: vi.fn(),
|
||||
finalizeRelease: vi.fn(),
|
||||
upload: vi.fn(),
|
||||
listReleaseAssets: vi.fn(),
|
||||
parseConfig: vi.fn(),
|
||||
isTag: vi.fn(),
|
||||
paths: vi.fn(),
|
||||
unmatchedPatterns: vi.fn(),
|
||||
uploadUrl: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@actions/core', () => ({
|
||||
setFailed: mocks.setFailed,
|
||||
setOutput: mocks.setOutput,
|
||||
}));
|
||||
|
||||
vi.mock('@actions/github', () => ({ getOctokit: mocks.getOctokit }));
|
||||
|
||||
vi.mock('../src/github', () => ({
|
||||
GitHubReleaser: mocks.GitHubReleaser,
|
||||
release: mocks.release,
|
||||
finalizeRelease: mocks.finalizeRelease,
|
||||
upload: mocks.upload,
|
||||
listReleaseAssets: mocks.listReleaseAssets,
|
||||
}));
|
||||
|
||||
vi.mock('../src/util', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../src/util')>();
|
||||
return {
|
||||
...actual,
|
||||
parseConfig: mocks.parseConfig,
|
||||
isTag: mocks.isTag,
|
||||
paths: mocks.paths,
|
||||
unmatchedPatterns: mocks.unmatchedPatterns,
|
||||
uploadUrl: mocks.uploadUrl,
|
||||
};
|
||||
});
|
||||
|
||||
import { run } from '../src/run';
|
||||
|
||||
const baseConfig: Config = {
|
||||
github_token: 'token',
|
||||
github_ref: 'refs/tags/v1.0.0',
|
||||
github_repository: 'owner/repo',
|
||||
input_files: [],
|
||||
input_draft: false,
|
||||
input_prerelease: false,
|
||||
input_preserve_order: false,
|
||||
input_overwrite_files: true,
|
||||
input_fail_on_unmatched_files: false,
|
||||
input_generate_release_notes: false,
|
||||
input_append_body: false,
|
||||
input_make_latest: undefined,
|
||||
};
|
||||
|
||||
const initialRelease: Release = {
|
||||
id: 41,
|
||||
upload_url: 'https://uploads.example.test/releases/41/assets{?name,label}',
|
||||
html_url: 'https://example.test/releases/41',
|
||||
tag_name: 'v1.0.0',
|
||||
name: 'v1.0.0',
|
||||
target_commitish: 'main',
|
||||
draft: true,
|
||||
prerelease: false,
|
||||
assets: [{ id: 1, name: 'existing.zip' }],
|
||||
};
|
||||
|
||||
const finalizedRelease: Release = {
|
||||
...initialRelease,
|
||||
id: 42,
|
||||
upload_url: 'https://uploads.example.test/releases/42/assets{?name,label}',
|
||||
html_url: 'https://example.test/releases/42',
|
||||
draft: false,
|
||||
};
|
||||
|
||||
const deferred = <T>() => {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((resolvePromise) => {
|
||||
resolve = resolvePromise;
|
||||
});
|
||||
return { promise, resolve };
|
||||
};
|
||||
|
||||
describe('run', () => {
|
||||
let config: Config;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
config = { ...baseConfig };
|
||||
mocks.parseConfig.mockImplementation(() => config);
|
||||
mocks.isTag.mockImplementation((ref: string) => ref.startsWith('refs/tags/'));
|
||||
mocks.unmatchedPatterns.mockReturnValue([]);
|
||||
mocks.paths.mockReturnValue([]);
|
||||
mocks.uploadUrl.mockImplementation((url: string) => url.split('{')[0]);
|
||||
mocks.getOctokit.mockReturnValue({ name: 'octokit' });
|
||||
mocks.release.mockResolvedValue({ release: initialRelease, created: true });
|
||||
mocks.finalizeRelease.mockResolvedValue(finalizedRelease);
|
||||
mocks.upload.mockResolvedValue(undefined);
|
||||
mocks.listReleaseAssets.mockResolvedValue([]);
|
||||
vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['an explicit tag', { github_ref: 'refs/heads/main', input_tag_name: 'v1.0.0' }],
|
||||
['a tag ref', { github_ref: 'refs/tags/v1.0.0', input_tag_name: undefined }],
|
||||
[
|
||||
'a draft without a tag',
|
||||
{ github_ref: 'refs/heads/main', input_tag_name: undefined, input_draft: true },
|
||||
],
|
||||
])('accepts %s', async (_name, patch) => {
|
||||
config = { ...config, ...patch };
|
||||
|
||||
await run();
|
||||
|
||||
expect(mocks.release).toHaveBeenCalledOnce();
|
||||
expect(mocks.setFailed).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports the documented tag requirement for a non-tag ref', async () => {
|
||||
config = { ...config, github_ref: 'refs/heads/main', input_tag_name: undefined };
|
||||
|
||||
await run();
|
||||
|
||||
expect(mocks.setFailed).toHaveBeenCalledWith('⚠️ GitHub Releases requires a tag');
|
||||
expect(mocks.release).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('warns with each unmatched input pattern when strict matching is disabled', async () => {
|
||||
config = { ...config, input_files: ['dist/*.zip'] };
|
||||
mocks.unmatchedPatterns.mockReturnValue(['dist/*.zip']);
|
||||
|
||||
await run();
|
||||
|
||||
expect(console.warn).toHaveBeenCalledWith("🤔 Pattern 'dist/*.zip' does not match any files.");
|
||||
expect(mocks.setFailed).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fails with the unmatched input pattern when strict matching is enabled', async () => {
|
||||
config = {
|
||||
...config,
|
||||
input_files: ['dist/*.zip'],
|
||||
input_fail_on_unmatched_files: true,
|
||||
};
|
||||
mocks.unmatchedPatterns.mockReturnValue(['dist/*.zip']);
|
||||
|
||||
await run();
|
||||
|
||||
expect(mocks.setFailed).toHaveBeenCalledWith(
|
||||
"⚠️ Pattern 'dist/*.zip' does not match any files.",
|
||||
);
|
||||
expect(mocks.release).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[false, 'warns', '🤔 dist/*.zip does not include a valid file.'],
|
||||
[true, 'fails', '⚠️ dist/*.zip does not include a valid file.'],
|
||||
])(
|
||||
'%s strict matching %s when resolved patterns contain no files',
|
||||
async (strict, _verb, message) => {
|
||||
config = {
|
||||
...config,
|
||||
input_files: ['dist/*.zip'],
|
||||
input_fail_on_unmatched_files: strict,
|
||||
};
|
||||
|
||||
await run();
|
||||
|
||||
if (strict) {
|
||||
expect(mocks.setFailed).toHaveBeenCalledWith(message);
|
||||
expect(mocks.finalizeRelease).not.toHaveBeenCalled();
|
||||
} else {
|
||||
expect(console.warn).toHaveBeenCalledWith(message);
|
||||
expect(mocks.finalizeRelease).toHaveBeenCalledOnce();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it('forwards valid files and the working directory through upload boundaries', async () => {
|
||||
config = {
|
||||
...config,
|
||||
input_files: ['dist/*.zip'],
|
||||
input_working_directory: 'fixture',
|
||||
};
|
||||
mocks.paths.mockReturnValue(['fixture/dist/action.zip']);
|
||||
mocks.upload.mockResolvedValue({ id: 7 });
|
||||
mocks.listReleaseAssets.mockResolvedValue([{ id: 7, name: 'action.zip' }]);
|
||||
|
||||
await run();
|
||||
|
||||
expect(mocks.paths).toHaveBeenCalledWith(['dist/*.zip'], 'fixture');
|
||||
expect(mocks.upload).toHaveBeenCalledWith(
|
||||
config,
|
||||
mocks.releaser,
|
||||
'https://uploads.example.test/releases/41/assets',
|
||||
'fixture/dist/action.zip',
|
||||
initialRelease.assets,
|
||||
initialRelease.id,
|
||||
);
|
||||
});
|
||||
|
||||
it('starts uploads concurrently by default', async () => {
|
||||
config = { ...config, input_files: ['one.zip', 'two.zip'] };
|
||||
mocks.paths.mockReturnValue(['one.zip', 'two.zip']);
|
||||
const first = deferred<{ id: number }>();
|
||||
const second = deferred<{ id: number }>();
|
||||
mocks.upload.mockImplementation((_config, _releaser, _url, path) =>
|
||||
path === 'one.zip' ? first.promise : second.promise,
|
||||
);
|
||||
|
||||
const result = run();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(mocks.upload).toHaveBeenCalledTimes(2);
|
||||
first.resolve({ id: 1 });
|
||||
second.resolve({ id: 2 });
|
||||
await result;
|
||||
});
|
||||
|
||||
it('waits for each upload when preserve_order is enabled', async () => {
|
||||
config = {
|
||||
...config,
|
||||
input_files: ['one.zip', 'two.zip'],
|
||||
input_preserve_order: true,
|
||||
};
|
||||
mocks.paths.mockReturnValue(['one.zip', 'two.zip']);
|
||||
const first = deferred<{ id: number }>();
|
||||
const second = deferred<{ id: number }>();
|
||||
mocks.upload.mockImplementation((_config, _releaser, _url, path) =>
|
||||
path === 'one.zip' ? first.promise : second.promise,
|
||||
);
|
||||
|
||||
const result = run();
|
||||
await Promise.resolve();
|
||||
expect(mocks.upload).toHaveBeenCalledTimes(1);
|
||||
|
||||
first.resolve({ id: 1 });
|
||||
await vi.waitFor(() => expect(mocks.upload).toHaveBeenCalledTimes(2));
|
||||
|
||||
second.resolve({ id: 2 });
|
||||
await result;
|
||||
});
|
||||
|
||||
it('keeps an existing draft unpublished until its upload completes', async () => {
|
||||
config = {
|
||||
...config,
|
||||
input_draft: false,
|
||||
input_prerelease: true,
|
||||
input_files: ['asset.zip'],
|
||||
};
|
||||
mocks.release.mockResolvedValue({ release: initialRelease, created: false });
|
||||
mocks.paths.mockReturnValue(['asset.zip']);
|
||||
const pendingUpload = deferred<{ id: number }>();
|
||||
mocks.upload.mockReturnValue(pendingUpload.promise);
|
||||
|
||||
const result = run();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(mocks.upload).toHaveBeenCalledOnce();
|
||||
expect(mocks.finalizeRelease).not.toHaveBeenCalled();
|
||||
|
||||
pendingUpload.resolve({ id: 7 });
|
||||
await result;
|
||||
|
||||
expect(mocks.finalizeRelease).toHaveBeenCalledWith(
|
||||
config,
|
||||
mocks.releaser,
|
||||
initialRelease,
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves an existing draft recoverable when an upload fails', async () => {
|
||||
config = {
|
||||
...config,
|
||||
input_draft: false,
|
||||
input_prerelease: true,
|
||||
input_files: ['asset.zip'],
|
||||
};
|
||||
mocks.release.mockResolvedValue({ release: initialRelease, created: false });
|
||||
mocks.paths.mockReturnValue(['asset.zip']);
|
||||
mocks.upload.mockRejectedValue(new Error('upload failed'));
|
||||
|
||||
await run();
|
||||
|
||||
expect(mocks.finalizeRelease).not.toHaveBeenCalled();
|
||||
expect(mocks.setFailed).toHaveBeenCalledWith('upload failed');
|
||||
});
|
||||
|
||||
it('finalizes after uploads and outputs only newly uploaded assets without uploader data', async () => {
|
||||
config = { ...config, input_files: ['one.zip', 'skipped.zip', 'two.zip'] };
|
||||
mocks.paths.mockReturnValue(['one.zip', 'skipped.zip', 'two.zip']);
|
||||
mocks.upload
|
||||
.mockResolvedValueOnce({ id: 7 })
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValueOnce({ id: 9 });
|
||||
mocks.listReleaseAssets.mockResolvedValue([
|
||||
{ id: 1, name: 'existing.zip', uploader: { login: 'someone' } },
|
||||
{ id: 7, name: 'one.zip', size: 10, uploader: { login: 'bot' } },
|
||||
{ id: 9, name: 'two.zip', label: 'Two', uploader: null },
|
||||
]);
|
||||
|
||||
await run();
|
||||
|
||||
expect(mocks.release.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
mocks.upload.mock.invocationCallOrder[0],
|
||||
);
|
||||
expect(mocks.upload.mock.invocationCallOrder[2]).toBeLessThan(
|
||||
mocks.finalizeRelease.mock.invocationCallOrder[0],
|
||||
);
|
||||
expect(mocks.finalizeRelease).toHaveBeenCalledWith(
|
||||
config,
|
||||
mocks.releaser,
|
||||
initialRelease,
|
||||
true,
|
||||
);
|
||||
expect(mocks.listReleaseAssets).toHaveBeenCalledWith(config, mocks.releaser, finalizedRelease);
|
||||
expect(mocks.setOutput).toHaveBeenCalledWith('assets', [
|
||||
{ id: 7, name: 'one.zip', size: 10 },
|
||||
{ id: 9, name: 'two.zip', label: 'Two' },
|
||||
]);
|
||||
expect(mocks.setOutput).toHaveBeenCalledWith('url', finalizedRelease.html_url);
|
||||
expect(mocks.setOutput).toHaveBeenCalledWith('id', '42');
|
||||
expect(mocks.setOutput).toHaveBeenCalledWith('upload_url', finalizedRelease.upload_url);
|
||||
});
|
||||
|
||||
it('outputs an empty asset list and skips refresh when no upload returns an ID', async () => {
|
||||
config = { ...config, input_files: ['skipped.zip'] };
|
||||
mocks.paths.mockReturnValue(['skipped.zip']);
|
||||
mocks.upload.mockResolvedValue(undefined);
|
||||
|
||||
await run();
|
||||
|
||||
expect(mocks.listReleaseAssets).not.toHaveBeenCalled();
|
||||
expect(mocks.setOutput).toHaveBeenCalledWith('assets', []);
|
||||
});
|
||||
|
||||
it('configures rate-limit and abuse callbacks without making live requests', async () => {
|
||||
await run();
|
||||
|
||||
const options = mocks.getOctokit.mock.calls[0][1];
|
||||
const request = { method: 'GET', url: '/repos/owner/repo', request: { retryCount: 0 } };
|
||||
expect(options.throttle.onRateLimit(5, request)).toBe(true);
|
||||
expect(
|
||||
options.throttle.onRateLimit(5, { ...request, request: { retryCount: 1 } }),
|
||||
).toBeUndefined();
|
||||
expect(options.throttle.onAbuseLimit(10, request)).toBeUndefined();
|
||||
expect(console.warn).toHaveBeenCalledWith(
|
||||
'Request quota exhausted for request GET /repos/owner/repo',
|
||||
);
|
||||
expect(console.warn).toHaveBeenCalledWith('Abuse detected for request GET /repos/owner/repo');
|
||||
});
|
||||
|
||||
it.each([
|
||||
[new Error('release failed'), 'release failed'],
|
||||
['release failed', 'release failed'],
|
||||
[{ message: 'release failed' }, 'release failed'],
|
||||
[null, 'Unknown error'],
|
||||
[undefined, 'Unknown error'],
|
||||
])('normalizes a thrown value before reporting failure', async (thrown, expected) => {
|
||||
mocks.parseConfig.mockImplementation(() => {
|
||||
throw thrown;
|
||||
});
|
||||
|
||||
await run();
|
||||
|
||||
expect(mocks.setFailed).toHaveBeenCalledWith(expected);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
+151
-161
@@ -1,6 +1,11 @@
|
||||
import {
|
||||
alignAssetName,
|
||||
errorMessage,
|
||||
expandHomePattern,
|
||||
isTag,
|
||||
normalizeFilePattern,
|
||||
normalizeGlobPattern,
|
||||
normalizeTagName,
|
||||
parseConfig,
|
||||
parseInputFiles,
|
||||
paths,
|
||||
@@ -21,6 +26,26 @@ describe('util', () => {
|
||||
'https://uploads.github.com/repos/octocat/Hello-World/releases/1/assets',
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves a URL without a template unchanged', () => {
|
||||
assert.equal(
|
||||
uploadUrl('https://uploads.github.com/repos/octocat/Hello-World/releases/1/assets'),
|
||||
'https://uploads.github.com/repos/octocat/Hello-World/releases/1/assets',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('errorMessage', () => {
|
||||
it.each([
|
||||
[new Error('failed'), 'failed'],
|
||||
[{ message: 'failed' }, 'failed'],
|
||||
['failed', 'failed'],
|
||||
[42, '42'],
|
||||
[null, 'Unknown error'],
|
||||
[undefined, 'Unknown error'],
|
||||
])('normalizes unknown thrown values', (error, expected) => {
|
||||
expect(errorMessage(error)).toBe(expected);
|
||||
});
|
||||
});
|
||||
describe('parseInputFiles', () => {
|
||||
it('parses empty strings', () => {
|
||||
@@ -170,6 +195,29 @@ describe('util', () => {
|
||||
});
|
||||
});
|
||||
describe('parseConfig', () => {
|
||||
const baseParsedConfig = {
|
||||
github_ref: '',
|
||||
github_repository: '',
|
||||
github_token: '',
|
||||
input_working_directory: undefined,
|
||||
input_append_body: false,
|
||||
input_body: undefined,
|
||||
input_body_path: undefined,
|
||||
input_draft: undefined,
|
||||
input_prerelease: undefined,
|
||||
input_preserve_order: undefined,
|
||||
input_files: [],
|
||||
input_overwrite_files: undefined,
|
||||
input_name: undefined,
|
||||
input_tag_name: undefined,
|
||||
input_fail_on_unmatched_files: false,
|
||||
input_target_commitish: undefined,
|
||||
input_discussion_category_name: undefined,
|
||||
input_generate_release_notes: false,
|
||||
input_previous_tag: undefined,
|
||||
input_make_latest: undefined,
|
||||
};
|
||||
|
||||
it('parses basic config', () => {
|
||||
assert.deepStrictEqual(
|
||||
parseConfig({
|
||||
@@ -182,55 +230,22 @@ describe('util', () => {
|
||||
INPUT_TARGET_COMMITISH: '',
|
||||
INPUT_DISCUSSION_CATEGORY_NAME: '',
|
||||
}),
|
||||
{
|
||||
github_ref: '',
|
||||
github_repository: '',
|
||||
github_token: '',
|
||||
input_working_directory: undefined,
|
||||
input_append_body: false,
|
||||
input_body: undefined,
|
||||
input_body_path: undefined,
|
||||
input_draft: undefined,
|
||||
input_prerelease: undefined,
|
||||
input_preserve_order: undefined,
|
||||
input_files: [],
|
||||
input_overwrite_files: undefined,
|
||||
input_name: undefined,
|
||||
input_tag_name: undefined,
|
||||
input_fail_on_unmatched_files: false,
|
||||
input_target_commitish: undefined,
|
||||
input_discussion_category_name: undefined,
|
||||
input_generate_release_notes: false,
|
||||
input_make_latest: undefined,
|
||||
},
|
||||
baseParsedConfig,
|
||||
);
|
||||
});
|
||||
|
||||
it('treats an empty draft input as omitted', () => {
|
||||
assert.strictEqual(parseConfig({ INPUT_DRAFT: '' }).input_draft, undefined);
|
||||
});
|
||||
|
||||
it('parses basic config with commitish', () => {
|
||||
assert.deepStrictEqual(
|
||||
parseConfig({
|
||||
INPUT_TARGET_COMMITISH: 'affa18ef97bc9db20076945705aba8c516139abd',
|
||||
}),
|
||||
{
|
||||
github_ref: '',
|
||||
github_repository: '',
|
||||
github_token: '',
|
||||
input_working_directory: undefined,
|
||||
input_append_body: false,
|
||||
input_body: undefined,
|
||||
input_body_path: undefined,
|
||||
input_draft: undefined,
|
||||
input_prerelease: undefined,
|
||||
input_files: [],
|
||||
input_overwrite_files: undefined,
|
||||
input_preserve_order: undefined,
|
||||
input_name: undefined,
|
||||
input_tag_name: undefined,
|
||||
input_fail_on_unmatched_files: false,
|
||||
...baseParsedConfig,
|
||||
input_target_commitish: 'affa18ef97bc9db20076945705aba8c516139abd',
|
||||
input_discussion_category_name: undefined,
|
||||
input_generate_release_notes: false,
|
||||
input_make_latest: undefined,
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -240,25 +255,8 @@ describe('util', () => {
|
||||
INPUT_DISCUSSION_CATEGORY_NAME: 'releases',
|
||||
}),
|
||||
{
|
||||
github_ref: '',
|
||||
github_repository: '',
|
||||
github_token: '',
|
||||
input_working_directory: undefined,
|
||||
input_append_body: false,
|
||||
input_body: undefined,
|
||||
input_body_path: undefined,
|
||||
input_draft: undefined,
|
||||
input_prerelease: undefined,
|
||||
input_files: [],
|
||||
input_preserve_order: undefined,
|
||||
input_name: undefined,
|
||||
input_overwrite_files: undefined,
|
||||
input_tag_name: undefined,
|
||||
input_fail_on_unmatched_files: false,
|
||||
input_target_commitish: undefined,
|
||||
...baseParsedConfig,
|
||||
input_discussion_category_name: 'releases',
|
||||
input_generate_release_notes: false,
|
||||
input_make_latest: undefined,
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -269,30 +267,25 @@ describe('util', () => {
|
||||
INPUT_GENERATE_RELEASE_NOTES: 'true',
|
||||
}),
|
||||
{
|
||||
github_ref: '',
|
||||
github_repository: '',
|
||||
github_token: '',
|
||||
input_working_directory: undefined,
|
||||
input_append_body: false,
|
||||
input_body: undefined,
|
||||
input_body_path: undefined,
|
||||
input_draft: undefined,
|
||||
input_prerelease: undefined,
|
||||
input_preserve_order: undefined,
|
||||
input_files: [],
|
||||
input_overwrite_files: undefined,
|
||||
input_name: undefined,
|
||||
input_tag_name: undefined,
|
||||
input_fail_on_unmatched_files: false,
|
||||
input_target_commitish: undefined,
|
||||
input_discussion_category_name: undefined,
|
||||
...baseParsedConfig,
|
||||
input_generate_release_notes: true,
|
||||
input_make_latest: undefined,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('prefers GITHUB_TOKEN over token input for backwards compatibility', () => {
|
||||
it('supports an explicit previous tag for release notes generation', () => {
|
||||
assert.deepStrictEqual(
|
||||
parseConfig({
|
||||
INPUT_PREVIOUS_TAG: ' v1.2.3 ',
|
||||
}),
|
||||
{
|
||||
...baseParsedConfig,
|
||||
input_previous_tag: 'v1.2.3',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('prefers token input over GITHUB_TOKEN', () => {
|
||||
assert.deepStrictEqual(
|
||||
parseConfig({
|
||||
INPUT_DRAFT: 'false',
|
||||
@@ -302,25 +295,23 @@ describe('util', () => {
|
||||
INPUT_TOKEN: 'input-token',
|
||||
}),
|
||||
{
|
||||
github_ref: '',
|
||||
github_repository: '',
|
||||
github_token: 'env-token',
|
||||
input_working_directory: undefined,
|
||||
input_append_body: false,
|
||||
input_body: undefined,
|
||||
input_body_path: undefined,
|
||||
...baseParsedConfig,
|
||||
github_token: 'input-token',
|
||||
input_draft: false,
|
||||
input_prerelease: true,
|
||||
input_preserve_order: true,
|
||||
input_files: [],
|
||||
input_overwrite_files: undefined,
|
||||
input_name: undefined,
|
||||
input_tag_name: undefined,
|
||||
input_fail_on_unmatched_files: false,
|
||||
input_target_commitish: undefined,
|
||||
input_discussion_category_name: undefined,
|
||||
input_generate_release_notes: false,
|
||||
input_make_latest: undefined,
|
||||
},
|
||||
);
|
||||
});
|
||||
it('falls back to GITHUB_TOKEN when token input is empty', () => {
|
||||
assert.deepStrictEqual(
|
||||
parseConfig({
|
||||
GITHUB_TOKEN: 'env-token',
|
||||
INPUT_TOKEN: ' ',
|
||||
}),
|
||||
{
|
||||
...baseParsedConfig,
|
||||
github_token: 'env-token',
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -332,25 +323,10 @@ describe('util', () => {
|
||||
INPUT_TOKEN: 'input-token',
|
||||
}),
|
||||
{
|
||||
github_ref: '',
|
||||
github_repository: '',
|
||||
...baseParsedConfig,
|
||||
github_token: 'input-token',
|
||||
input_working_directory: undefined,
|
||||
input_append_body: false,
|
||||
input_body: undefined,
|
||||
input_body_path: undefined,
|
||||
input_draft: false,
|
||||
input_prerelease: true,
|
||||
input_preserve_order: undefined,
|
||||
input_files: [],
|
||||
input_overwrite_files: undefined,
|
||||
input_name: undefined,
|
||||
input_tag_name: undefined,
|
||||
input_fail_on_unmatched_files: false,
|
||||
input_target_commitish: undefined,
|
||||
input_discussion_category_name: undefined,
|
||||
input_generate_release_notes: false,
|
||||
input_make_latest: undefined,
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -361,25 +337,9 @@ describe('util', () => {
|
||||
INPUT_PRERELEASE: 'true',
|
||||
}),
|
||||
{
|
||||
github_ref: '',
|
||||
github_repository: '',
|
||||
github_token: '',
|
||||
input_working_directory: undefined,
|
||||
input_append_body: false,
|
||||
input_body: undefined,
|
||||
input_body_path: undefined,
|
||||
...baseParsedConfig,
|
||||
input_draft: false,
|
||||
input_prerelease: true,
|
||||
input_preserve_order: undefined,
|
||||
input_files: [],
|
||||
input_overwrite_files: undefined,
|
||||
input_name: undefined,
|
||||
input_tag_name: undefined,
|
||||
input_fail_on_unmatched_files: false,
|
||||
input_target_commitish: undefined,
|
||||
input_discussion_category_name: undefined,
|
||||
input_generate_release_notes: false,
|
||||
input_make_latest: undefined,
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -389,24 +349,7 @@ describe('util', () => {
|
||||
INPUT_MAKE_LATEST: 'false',
|
||||
}),
|
||||
{
|
||||
github_ref: '',
|
||||
github_repository: '',
|
||||
github_token: '',
|
||||
input_working_directory: undefined,
|
||||
input_append_body: false,
|
||||
input_body: undefined,
|
||||
input_body_path: undefined,
|
||||
input_draft: undefined,
|
||||
input_prerelease: undefined,
|
||||
input_preserve_order: undefined,
|
||||
input_files: [],
|
||||
input_name: undefined,
|
||||
input_overwrite_files: undefined,
|
||||
input_tag_name: undefined,
|
||||
input_fail_on_unmatched_files: false,
|
||||
input_target_commitish: undefined,
|
||||
input_discussion_category_name: undefined,
|
||||
input_generate_release_notes: false,
|
||||
...baseParsedConfig,
|
||||
input_make_latest: 'false',
|
||||
},
|
||||
);
|
||||
@@ -417,28 +360,15 @@ describe('util', () => {
|
||||
INPUT_APPEND_BODY: 'true',
|
||||
}),
|
||||
{
|
||||
github_ref: '',
|
||||
github_repository: '',
|
||||
github_token: '',
|
||||
input_working_directory: undefined,
|
||||
...baseParsedConfig,
|
||||
input_append_body: true,
|
||||
input_body: undefined,
|
||||
input_body_path: undefined,
|
||||
input_draft: undefined,
|
||||
input_prerelease: undefined,
|
||||
input_preserve_order: undefined,
|
||||
input_files: [],
|
||||
input_overwrite_files: undefined,
|
||||
input_name: undefined,
|
||||
input_tag_name: undefined,
|
||||
input_fail_on_unmatched_files: false,
|
||||
input_target_commitish: undefined,
|
||||
input_discussion_category_name: undefined,
|
||||
input_generate_release_notes: false,
|
||||
input_make_latest: undefined,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('normalizes refs/tags-prefixed input_tag_name values', () => {
|
||||
expect(parseConfig({ INPUT_TAG_NAME: 'refs/tags/v1.2.3' }).input_tag_name).toBe('v1.2.3');
|
||||
});
|
||||
});
|
||||
describe('isTag', () => {
|
||||
it('returns true for tags', async () => {
|
||||
@@ -449,6 +379,16 @@ describe('util', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeTagName', () => {
|
||||
it('strips refs/tags/ from explicit tag names', () => {
|
||||
assert.equal(normalizeTagName('refs/tags/v1.2.3'), 'v1.2.3');
|
||||
});
|
||||
|
||||
it('leaves plain tag names unchanged', () => {
|
||||
assert.equal(normalizeTagName('v1.2.3'), 'v1.2.3');
|
||||
});
|
||||
});
|
||||
|
||||
describe('paths', () => {
|
||||
it('resolves files given a set of paths', async () => {
|
||||
assert.deepStrictEqual(paths(['tests/data/**/*', 'tests/data/does/not/exist/*']), [
|
||||
@@ -476,6 +416,56 @@ describe('util', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeGlobPattern', () => {
|
||||
it('preserves posix-style patterns on non-windows platforms', () => {
|
||||
assert.equal(normalizeGlobPattern('./dist/**/*.tgz', 'linux'), './dist/**/*.tgz');
|
||||
});
|
||||
|
||||
it('normalizes relative windows-style glob patterns', () => {
|
||||
assert.equal(
|
||||
normalizeGlobPattern('.\\release-assets\\rssguard-*win7.exe', 'win32'),
|
||||
'./release-assets/rssguard-*win7.exe',
|
||||
);
|
||||
});
|
||||
|
||||
it('normalizes absolute windows-style glob patterns', () => {
|
||||
assert.equal(
|
||||
normalizeGlobPattern('D:\\a\\repo\\build\\packages\\*', 'win32'),
|
||||
'D:/a/repo/build/packages/*',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('expandHomePattern', () => {
|
||||
it('expands a bare tilde to the provided home directory', () => {
|
||||
assert.equal(expandHomePattern('~', '/home/runner'), '/home/runner');
|
||||
});
|
||||
|
||||
it('expands posix-style tilde paths', () => {
|
||||
assert.equal(expandHomePattern('~/release.txt', '/home/runner'), '/home/runner/release.txt');
|
||||
});
|
||||
|
||||
it('leaves non-tilde paths unchanged', () => {
|
||||
assert.equal(expandHomePattern('./release.txt', '/home/runner'), './release.txt');
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeFilePattern', () => {
|
||||
it('expands tilde paths before globbing', () => {
|
||||
assert.equal(
|
||||
normalizeFilePattern('~/release-assets/*.tgz', 'linux', '/home/runner'),
|
||||
'/home/runner/release-assets/*.tgz',
|
||||
);
|
||||
});
|
||||
|
||||
it('expands tilde paths and normalizes windows separators', () => {
|
||||
assert.equal(
|
||||
normalizeFilePattern('~\\release-assets\\*.zip', 'win32', 'C:\\Users\\runner'),
|
||||
'C:/Users/runner/release-assets/*.zip',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('replaceSpacesWithDots', () => {
|
||||
it('replaces all spaces with dots', () => {
|
||||
expect(alignAssetName('John Doe.bla')).toBe('John.Doe.bla');
|
||||
|
||||
+12
-8
@@ -13,22 +13,22 @@ inputs:
|
||||
description: "Gives the release a custom name. Defaults to tag name"
|
||||
required: false
|
||||
tag_name:
|
||||
description: "Gives a tag name. Defaults to github.ref_name"
|
||||
description: "Gives a tag name. Defaults to github.ref_name. refs/tags/<name> values are normalized to <name>."
|
||||
required: false
|
||||
draft:
|
||||
description: "Creates a draft release. Defaults to false"
|
||||
description: "Keeps the release as a draft. Defaults to false. When reusing an existing draft release, set this to true to keep it draft; omit it to publish after upload. On immutable-release repositories, use this for prereleases that upload assets and publish the draft later."
|
||||
required: false
|
||||
prerelease:
|
||||
description: "Identify the release as a prerelease. Defaults to false"
|
||||
required: false
|
||||
preserve_order:
|
||||
description: "Preserver the order of the artifacts when uploading"
|
||||
description: "Upload artifacts sequentially in the provided order. This does not control the final display order GitHub uses for release assets."
|
||||
required: false
|
||||
files:
|
||||
description: "Newline-delimited list of path globs for asset files to upload"
|
||||
description: "Newline-delimited list of path globs for asset files to upload. Escape glob metacharacters when matching literal filenames that contain them. `~/...` expands to the runner home directory. On Windows, both \\ and / path separators are accepted. GitHub may normalize raw asset filenames that contain special characters; the action restores the asset label when possible, but the final download name remains GitHub-controlled."
|
||||
required: false
|
||||
working_directory:
|
||||
description: "Base directory to resolve 'files' globs against (defaults to job working-directory)"
|
||||
description: "Base directory to resolve 'files' globs against. Defaults to the workspace root used by the action step."
|
||||
required: false
|
||||
overwrite_files:
|
||||
description: "Overwrite existing files with the same name. Defaults to true"
|
||||
@@ -41,11 +41,11 @@ inputs:
|
||||
description: "Repository to make releases against, in <owner>/<repo> format"
|
||||
required: false
|
||||
token:
|
||||
description: "Authorized secret GitHub Personal Access Token. Defaults to github.token"
|
||||
description: "Authorized GitHub token or PAT. Defaults to github.token when omitted. A non-empty explicit token overrides GITHUB_TOKEN. Passing an empty string treats the token as unset."
|
||||
required: false
|
||||
default: ${{ github.token }}
|
||||
target_commitish:
|
||||
description: "Commitish value that determines where the Git tag is created from. Can be any branch or commit SHA."
|
||||
description: "Commitish value that determines where the Git tag is created from. Can be any branch or commit SHA. When creating a new tag for an older commit, `github.token` may not have permission to create the ref; use a PAT or another token with sufficient contents permissions if you hit 403 `Resource not accessible by integration`."
|
||||
required: false
|
||||
discussion_category_name:
|
||||
description: "If specified, a discussion of the specified category is created and linked to the release. The value must be a category that already exists in the repository. If there is already a discussion linked to the release, this parameter is ignored."
|
||||
@@ -53,6 +53,10 @@ inputs:
|
||||
generate_release_notes:
|
||||
description: "Whether to automatically generate the name and body for this release. If name is specified, the specified name will be used; otherwise, a name will be automatically generated. If body is specified, the body will be pre-pended to the automatically generated notes."
|
||||
required: false
|
||||
previous_tag:
|
||||
description: "Optional. When generate_release_notes is enabled, use this tag as GitHub's previous_tag_name comparison base. If omitted, GitHub chooses the comparison base automatically."
|
||||
required: false
|
||||
default: ""
|
||||
append_body:
|
||||
description: "Append to existing body instead of overwriting it. Default is false."
|
||||
required: false
|
||||
@@ -71,7 +75,7 @@ outputs:
|
||||
assets:
|
||||
description: "JSON array containing information about each uploaded asset, in the format given [here](https://docs.github.com/en/rest/reference/repos#upload-a-release-asset--code-samples) (minus the `uploader` field)"
|
||||
runs:
|
||||
using: "node20"
|
||||
using: "node24"
|
||||
main: "dist/index.js"
|
||||
branding:
|
||||
color: "green"
|
||||
|
||||
Vendored
+31
-35
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",
|
||||
"version": "2.5.2",
|
||||
"version": "3.0.1",
|
||||
"private": true,
|
||||
"description": "GitHub Action for creating GitHub Releases",
|
||||
"main": "lib/main.js",
|
||||
"scripts": {
|
||||
"build": "esbuild src/main.ts --bundle --platform=node --format=cjs --target=node20 --outfile=dist/index.js --minify",
|
||||
"build-debug": "esbuild src/main.ts --bundle --platform=node --format=cjs --target=node20 --outfile=dist/index.js --sourcemap --keep-names",
|
||||
"build": "esbuild src/main.ts --bundle --platform=node --format=cjs --target=node24 --outfile=dist/index.js --minify",
|
||||
"build-debug": "esbuild src/main.ts --bundle --platform=node --format=cjs --target=node24 --outfile=dist/index.js --sourcemap --keep-names",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest --coverage",
|
||||
"fmt": "prettier --write \"src/**/*.ts\" \"__tests__/**/*.ts\"",
|
||||
"fmtcheck": "prettier --check \"src/**/*.ts\" \"__tests__/**/*.ts\"",
|
||||
"updatetag": "git tag -d v2 && git push origin :v2 && git tag -a v2 -m '' && git push origin v2"
|
||||
"updatetag": "git tag -d v3 >/dev/null 2>&1 || true; git push origin :v3 >/dev/null 2>&1 || true; git tag -a v3 -m '' && git push origin v3"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -21,24 +21,24 @@
|
||||
"actions"
|
||||
],
|
||||
"author": "softprops",
|
||||
"engines": {
|
||||
"node": ">=24"
|
||||
},
|
||||
"dependencies": {
|
||||
"@actions/core": "^3.0.0",
|
||||
"@actions/github": "^9.0.0",
|
||||
"@actions/core": "^3.0.1",
|
||||
"@actions/github": "^9.1.1",
|
||||
"@octokit/plugin-retry": "^8.1.0",
|
||||
"@octokit/plugin-throttling": "^11.0.3",
|
||||
"glob": "^13.0.6",
|
||||
"mime-types": "^3.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/glob": "^9.0.0",
|
||||
"@types/mime-types": "^3.0.1",
|
||||
"@types/node": "^20.19.37",
|
||||
"@vitest/coverage-v8": "^4.1.0",
|
||||
"esbuild": "^0.27.3",
|
||||
"prettier": "3.8.1",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-formatter": "^7.2.2",
|
||||
"@types/node": "^24",
|
||||
"@vitest/coverage-v8": "^4.1.9",
|
||||
"esbuild": "^0.28.1",
|
||||
"prettier": "3.9.0",
|
||||
"typescript": "^7.0.2",
|
||||
"vitest": "^4.0.4"
|
||||
}
|
||||
}
|
||||
|
||||
+420
-193
@@ -1,12 +1,26 @@
|
||||
import { GitHub } from '@actions/github/lib/utils';
|
||||
import { statSync } from 'fs';
|
||||
import { open } from 'fs/promises';
|
||||
import { open, type FileHandle } from 'fs/promises';
|
||||
import { lookup } from 'mime-types';
|
||||
import { basename } from 'path';
|
||||
import { alignAssetName, Config, isTag, releaseBody } from './util';
|
||||
import { alignAssetName, Config, errorMessage, isTag, normalizeTagName, releaseBody } from './util';
|
||||
|
||||
type GitHub = InstanceType<typeof GitHub>;
|
||||
|
||||
type UploadChunk = ArrayBuffer | Uint8Array<ArrayBufferLike>;
|
||||
type UploadBody = ReadableStream<Uint8Array<ArrayBufferLike>>;
|
||||
|
||||
const fileUploadStream = (fileHandle: FileHandle): UploadBody => {
|
||||
const source = fileHandle.readableWebStream() as ReadableStream<UploadChunk>;
|
||||
return source.pipeThrough(
|
||||
new TransformStream<UploadChunk, Uint8Array<ArrayBufferLike>>({
|
||||
transform(chunk, controller) {
|
||||
controller.enqueue(chunk instanceof Uint8Array ? chunk : new Uint8Array(chunk));
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
export interface ReleaseAsset {
|
||||
name: string;
|
||||
mime: string;
|
||||
@@ -31,43 +45,84 @@ export interface ReleaseResult {
|
||||
created: boolean;
|
||||
}
|
||||
|
||||
type ReleaseNotesParams = {
|
||||
owner: string;
|
||||
repo: string;
|
||||
tag_name: string;
|
||||
target_commitish: string | undefined;
|
||||
previous_tag_name?: string;
|
||||
};
|
||||
|
||||
type ReleaseMutationParams = {
|
||||
owner: string;
|
||||
repo: string;
|
||||
tag_name: string;
|
||||
name: string;
|
||||
body: string | undefined;
|
||||
draft: boolean | undefined;
|
||||
prerelease: boolean | undefined;
|
||||
target_commitish: string | undefined;
|
||||
discussion_category_name: string | undefined;
|
||||
generate_release_notes: boolean | undefined;
|
||||
make_latest: 'true' | 'false' | 'legacy' | undefined;
|
||||
previous_tag_name?: string;
|
||||
};
|
||||
|
||||
class ReleaseCreationError extends Error {
|
||||
readonly status = 404;
|
||||
|
||||
constructor(message: string, cause: unknown) {
|
||||
super(message, { cause });
|
||||
this.name = 'ReleaseCreationError';
|
||||
}
|
||||
}
|
||||
|
||||
class ReleaseAccessError extends Error {
|
||||
readonly status = 404;
|
||||
|
||||
constructor(message: string, cause: unknown) {
|
||||
super(message, { cause });
|
||||
this.name = 'ReleaseAccessError';
|
||||
}
|
||||
}
|
||||
|
||||
const repositoryAccessGuidance = (owner: string, repo: string): string =>
|
||||
`Verify that ${owner}/${repo} exists under the expected owner, the token can access it, the repository is selected when using a fine-grained PAT, and the token has Contents: write permission.`;
|
||||
|
||||
const releaseCreation404Message = (
|
||||
owner: string,
|
||||
repo: string,
|
||||
discussionCategory: string | undefined,
|
||||
error: unknown,
|
||||
): string => {
|
||||
const discussionGuidance = discussionCategory
|
||||
? ` Also verify that Discussions and the requested category "${discussionCategory}" are enabled.`
|
||||
: '';
|
||||
|
||||
return `GitHub returned 404 while creating the release. ${repositoryAccessGuidance(owner, repo)}${discussionGuidance} GitHub response: ${errorMessage(error)}`;
|
||||
};
|
||||
|
||||
const releaseLookup404Message = (owner: string, repo: string, error: unknown): string =>
|
||||
`GitHub returned 404 while checking existing releases. ${repositoryAccessGuidance(owner, repo)} GitHub response: ${errorMessage(error)}`;
|
||||
|
||||
export interface Releaser {
|
||||
getReleaseByTag(params: { owner: string; repo: string; tag: string }): Promise<{ data: Release }>;
|
||||
|
||||
createRelease(params: {
|
||||
owner: string;
|
||||
repo: string;
|
||||
tag_name: string;
|
||||
name: string;
|
||||
body: string | undefined;
|
||||
draft: boolean | undefined;
|
||||
prerelease: boolean | undefined;
|
||||
target_commitish: string | undefined;
|
||||
discussion_category_name: string | undefined;
|
||||
generate_release_notes: boolean | undefined;
|
||||
make_latest: 'true' | 'false' | 'legacy' | undefined;
|
||||
}): Promise<{ data: Release }>;
|
||||
createRelease(params: ReleaseMutationParams): Promise<{ data: Release }>;
|
||||
|
||||
updateRelease(params: {
|
||||
owner: string;
|
||||
repo: string;
|
||||
release_id: number;
|
||||
tag_name: string;
|
||||
target_commitish: string;
|
||||
name: string;
|
||||
body: string | undefined;
|
||||
draft: boolean | undefined;
|
||||
prerelease: boolean | undefined;
|
||||
discussion_category_name: string | undefined;
|
||||
generate_release_notes: boolean | undefined;
|
||||
make_latest: 'true' | 'false' | 'legacy' | undefined;
|
||||
}): Promise<{ data: Release }>;
|
||||
updateRelease(
|
||||
params: ReleaseMutationParams & {
|
||||
release_id: number;
|
||||
target_commitish: string;
|
||||
},
|
||||
): Promise<{ data: Release }>;
|
||||
|
||||
finalizeRelease(params: {
|
||||
owner: string;
|
||||
repo: string;
|
||||
release_id: number;
|
||||
make_latest: 'true' | 'false' | 'legacy' | undefined;
|
||||
discussion_category_name: string | undefined;
|
||||
}): Promise<{ data: Release }>;
|
||||
|
||||
allReleases(params: { owner: string; repo: string }): AsyncIterable<{ data: Release[] }>;
|
||||
@@ -78,7 +133,12 @@ export interface Releaser {
|
||||
release_id: number;
|
||||
}): Promise<Array<{ id: number; name: string; label?: string | null; [key: string]: any }>>;
|
||||
|
||||
deleteReleaseAsset(params: { owner: string; repo: string; asset_id: number }): Promise<void>;
|
||||
deleteReleaseAsset(params: {
|
||||
owner: string;
|
||||
repo: string;
|
||||
release_id: number;
|
||||
asset_id: number;
|
||||
}): Promise<void>;
|
||||
|
||||
deleteRelease(params: { owner: string; repo: string; release_id: number }): Promise<void>;
|
||||
|
||||
@@ -95,7 +155,7 @@ export interface Releaser {
|
||||
size: number;
|
||||
mime: string;
|
||||
token: string;
|
||||
data: any;
|
||||
data: UploadBody;
|
||||
}): Promise<{ status: number; data: any }>;
|
||||
}
|
||||
|
||||
@@ -113,12 +173,7 @@ export class GitHubReleaser implements Releaser {
|
||||
return this.github.rest.repos.getReleaseByTag(params);
|
||||
}
|
||||
|
||||
async getReleaseNotes(params: {
|
||||
owner: string;
|
||||
repo: string;
|
||||
tag_name: string;
|
||||
target_commitish: string | undefined;
|
||||
}): Promise<{
|
||||
async getReleaseNotes(params: ReleaseNotesParams): Promise<{
|
||||
data: {
|
||||
name: string;
|
||||
body: string;
|
||||
@@ -127,75 +182,55 @@ export class GitHubReleaser implements Releaser {
|
||||
return await this.github.rest.repos.generateReleaseNotes(params);
|
||||
}
|
||||
|
||||
private async prepareReleaseMutation<T extends ReleaseMutationParams>(
|
||||
params: T,
|
||||
): Promise<Omit<T, 'previous_tag_name'>> {
|
||||
const { previous_tag_name, ...releaseParams } = params;
|
||||
|
||||
if (
|
||||
typeof releaseParams.make_latest === 'string' &&
|
||||
!['true', 'false', 'legacy'].includes(releaseParams.make_latest)
|
||||
) {
|
||||
releaseParams.make_latest = undefined;
|
||||
}
|
||||
if (releaseParams.generate_release_notes) {
|
||||
const releaseNotes = await this.getReleaseNotes({
|
||||
owner: releaseParams.owner,
|
||||
repo: releaseParams.repo,
|
||||
tag_name: releaseParams.tag_name,
|
||||
target_commitish: releaseParams.target_commitish,
|
||||
previous_tag_name,
|
||||
});
|
||||
releaseParams.generate_release_notes = false;
|
||||
if (releaseParams.body) {
|
||||
releaseParams.body = `${releaseParams.body}\n\n${releaseNotes.data.body}`;
|
||||
} else {
|
||||
releaseParams.body = releaseNotes.data.body;
|
||||
}
|
||||
}
|
||||
releaseParams.body = releaseParams.body
|
||||
? this.truncateReleaseNotes(releaseParams.body)
|
||||
: undefined;
|
||||
return releaseParams;
|
||||
}
|
||||
|
||||
truncateReleaseNotes(input: string): string {
|
||||
// release notes can be a maximum of 125000 characters
|
||||
const githubNotesMaxCharLength = 125000;
|
||||
return input.substring(0, githubNotesMaxCharLength - 1);
|
||||
}
|
||||
|
||||
async createRelease(params: {
|
||||
owner: string;
|
||||
repo: string;
|
||||
tag_name: string;
|
||||
name: string;
|
||||
body: string | undefined;
|
||||
draft: boolean | undefined;
|
||||
prerelease: boolean | undefined;
|
||||
target_commitish: string | undefined;
|
||||
discussion_category_name: string | undefined;
|
||||
generate_release_notes: boolean | undefined;
|
||||
make_latest: 'true' | 'false' | 'legacy' | undefined;
|
||||
}): Promise<{ data: Release }> {
|
||||
if (
|
||||
typeof params.make_latest === 'string' &&
|
||||
!['true', 'false', 'legacy'].includes(params.make_latest)
|
||||
) {
|
||||
params.make_latest = undefined;
|
||||
}
|
||||
if (params.generate_release_notes) {
|
||||
const releaseNotes = await this.getReleaseNotes(params);
|
||||
params.generate_release_notes = false;
|
||||
if (params.body) {
|
||||
params.body = `${params.body}\n\n${releaseNotes.data.body}`;
|
||||
} else {
|
||||
params.body = releaseNotes.data.body;
|
||||
}
|
||||
}
|
||||
params.body = params.body ? this.truncateReleaseNotes(params.body) : undefined;
|
||||
return this.github.rest.repos.createRelease(params);
|
||||
async createRelease(params: ReleaseMutationParams): Promise<{ data: Release }> {
|
||||
return this.github.rest.repos.createRelease(await this.prepareReleaseMutation(params));
|
||||
}
|
||||
|
||||
async updateRelease(params: {
|
||||
owner: string;
|
||||
repo: string;
|
||||
release_id: number;
|
||||
tag_name: string;
|
||||
target_commitish: string;
|
||||
name: string;
|
||||
body: string | undefined;
|
||||
draft: boolean | undefined;
|
||||
prerelease: boolean | undefined;
|
||||
discussion_category_name: string | undefined;
|
||||
generate_release_notes: boolean | undefined;
|
||||
make_latest: 'true' | 'false' | 'legacy' | undefined;
|
||||
}): Promise<{ data: Release }> {
|
||||
if (
|
||||
typeof params.make_latest === 'string' &&
|
||||
!['true', 'false', 'legacy'].includes(params.make_latest)
|
||||
) {
|
||||
params.make_latest = undefined;
|
||||
}
|
||||
if (params.generate_release_notes) {
|
||||
const releaseNotes = await this.getReleaseNotes(params);
|
||||
params.generate_release_notes = false;
|
||||
if (params.body) {
|
||||
params.body = `${params.body}\n\n${releaseNotes.data.body}`;
|
||||
} else {
|
||||
params.body = releaseNotes.data.body;
|
||||
}
|
||||
}
|
||||
params.body = params.body ? this.truncateReleaseNotes(params.body) : undefined;
|
||||
return this.github.rest.repos.updateRelease(params);
|
||||
async updateRelease(
|
||||
params: ReleaseMutationParams & {
|
||||
release_id: number;
|
||||
target_commitish: string;
|
||||
},
|
||||
): Promise<{ data: Release }> {
|
||||
return this.github.rest.repos.updateRelease(await this.prepareReleaseMutation(params));
|
||||
}
|
||||
|
||||
async finalizeRelease(params: {
|
||||
@@ -203,6 +238,7 @@ export class GitHubReleaser implements Releaser {
|
||||
repo: string;
|
||||
release_id: number;
|
||||
make_latest: 'true' | 'false' | 'legacy' | undefined;
|
||||
discussion_category_name: string | undefined;
|
||||
}) {
|
||||
return await this.github.rest.repos.updateRelease({
|
||||
owner: params.owner,
|
||||
@@ -210,6 +246,7 @@ export class GitHubReleaser implements Releaser {
|
||||
release_id: params.release_id,
|
||||
draft: false,
|
||||
make_latest: params.make_latest,
|
||||
discussion_category_name: params.discussion_category_name,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -234,9 +271,34 @@ export class GitHubReleaser implements Releaser {
|
||||
async deleteReleaseAsset(params: {
|
||||
owner: string;
|
||||
repo: string;
|
||||
release_id: number;
|
||||
asset_id: number;
|
||||
}): Promise<void> {
|
||||
await this.github.rest.repos.deleteReleaseAsset(params);
|
||||
const { release_id, ...githubParams } = params;
|
||||
try {
|
||||
await this.github.rest.repos.deleteReleaseAsset(githubParams);
|
||||
} catch (error: unknown) {
|
||||
const status =
|
||||
(error as { status?: number; response?: { status?: number } })?.status ??
|
||||
(error as { response?: { status?: number } })?.response?.status;
|
||||
if (status !== 404) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.github.request(
|
||||
'DELETE /repos/{owner}/{repo}/releases/{release_id}/assets/{asset_id}',
|
||||
params,
|
||||
);
|
||||
} catch (fallbackError: unknown) {
|
||||
throw new AggregateError(
|
||||
[error, fallbackError],
|
||||
`Failed to delete release asset ${params.asset_id}. GitHub endpoint: ${errorMessage(
|
||||
error,
|
||||
)}; release-scoped endpoint: ${errorMessage(fallbackError)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async deleteRelease(params: { owner: string; repo: string; release_id: number }): Promise<void> {
|
||||
@@ -258,7 +320,7 @@ export class GitHubReleaser implements Releaser {
|
||||
size: number;
|
||||
mime: string;
|
||||
token: string;
|
||||
data: any;
|
||||
data: UploadBody;
|
||||
}): Promise<{ status: number; data: any }> {
|
||||
return this.github.request({
|
||||
method: 'POST',
|
||||
@@ -285,23 +347,55 @@ export const mimeOrDefault = (path: string): string => {
|
||||
return lookup(path) || 'application/octet-stream';
|
||||
};
|
||||
|
||||
const releaseAssetMatchesName = (
|
||||
name: string,
|
||||
asset: { name: string; label?: string | null },
|
||||
): boolean => asset.name === name || asset.name === alignAssetName(name) || asset.label === name;
|
||||
|
||||
const isReleaseAssetUpdateNotFound = (error: any): boolean => {
|
||||
const errorStatus = error?.status ?? error?.response?.status;
|
||||
const requestUrl = error?.request?.url;
|
||||
const errorMessage = error?.message;
|
||||
const isReleaseAssetRequest =
|
||||
typeof requestUrl === 'string' &&
|
||||
(/\/releases\/assets\//.test(requestUrl) || /\/releases\/\d+\/assets(?:\?|$)/.test(requestUrl));
|
||||
|
||||
return (
|
||||
errorStatus === 404 &&
|
||||
(isReleaseAssetRequest ||
|
||||
(typeof errorMessage === 'string' && errorMessage.includes('update-a-release-asset')))
|
||||
);
|
||||
};
|
||||
|
||||
const isImmutableReleaseAssetUploadFailure = (error: any): boolean => {
|
||||
const errorStatus = error?.status ?? error?.response?.status;
|
||||
const errorMessage = error?.response?.data?.message ?? error?.message;
|
||||
|
||||
return errorStatus === 422 && /immutable release/i.test(String(errorMessage));
|
||||
};
|
||||
|
||||
const immutableReleaseAssetUploadMessage = (
|
||||
name: string,
|
||||
prerelease: boolean | undefined,
|
||||
): string =>
|
||||
prerelease
|
||||
? `Cannot upload asset ${name} to an immutable release. GitHub only allows asset uploads before a release is published, but draft prereleases publish with the release.published event instead of release.prereleased. If you need prereleases with assets on an immutable-release repository, keep the release as a draft with draft: true, then publish it later from that draft and subscribe downstream workflows to release.published.`
|
||||
: `Cannot upload asset ${name} to an immutable release. GitHub only allows asset uploads before a release is published, so upload assets to a draft release before you publish it.`;
|
||||
|
||||
export const upload = async (
|
||||
config: Config,
|
||||
releaser: Releaser,
|
||||
url: string,
|
||||
path: string,
|
||||
currentAssets: Array<{ id: number; name: string; label?: string | null }>,
|
||||
releaseId: number,
|
||||
): Promise<any> => {
|
||||
const [owner, repo] = config.github_repository.split('/');
|
||||
const { name, mime, size } = asset(path);
|
||||
const releaseIdMatch = url.match(/\/releases\/(\d+)\/assets/);
|
||||
const releaseId = releaseIdMatch ? Number(releaseIdMatch[1]) : undefined;
|
||||
const currentAsset = currentAssets.find(
|
||||
// note: GitHub renames asset filenames that have special characters, non-alphanumeric characters, and leading or trailing periods. The "List release assets" endpoint lists the renamed filenames.
|
||||
// due to this renaming we need to be mindful when we compare the file name we're uploading with a name github may already have rewritten for logical comparison
|
||||
// see https://docs.github.com/en/rest/releases/assets?apiVersion=2022-11-28#upload-a-release-asset
|
||||
({ name: currentName, label: currentLabel }) =>
|
||||
currentName === name || currentName === alignAssetName(name) || currentLabel === name,
|
||||
// GitHub can rewrite uploaded asset names, so compare against both the raw name
|
||||
// GitHub returns and the restored label we set when available.
|
||||
(currentAsset) => releaseAssetMatchesName(name, currentAsset),
|
||||
);
|
||||
if (currentAsset) {
|
||||
if (config.input_overwrite_files === false) {
|
||||
@@ -313,12 +407,39 @@ export const upload = async (
|
||||
asset_id: currentAsset.id || 1,
|
||||
owner,
|
||||
repo,
|
||||
release_id: releaseId,
|
||||
});
|
||||
}
|
||||
}
|
||||
console.log(`⬆️ Uploading ${name}...`);
|
||||
const endpoint = new URL(url);
|
||||
endpoint.searchParams.append('name', name);
|
||||
const findReleaseAsset = async (
|
||||
matches: (asset: { id: number; name: string; label?: string | null }) => boolean,
|
||||
attempts: number = 3,
|
||||
) => {
|
||||
if (releaseId === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
for (let attempt = 1; attempt <= attempts; attempt++) {
|
||||
const latestAssets = await releaser.listReleaseAssets({
|
||||
owner,
|
||||
repo,
|
||||
release_id: releaseId,
|
||||
});
|
||||
const latestAsset = latestAssets.find(matches);
|
||||
if (latestAsset) {
|
||||
return latestAsset;
|
||||
}
|
||||
|
||||
if (attempt < attempts) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
const uploadAsset = async () => {
|
||||
const fh = await open(path);
|
||||
try {
|
||||
@@ -327,15 +448,61 @@ export const upload = async (
|
||||
size,
|
||||
mime,
|
||||
token: config.github_token,
|
||||
data: fh.readableWebStream({ type: 'bytes' }),
|
||||
data: fileUploadStream(fh),
|
||||
});
|
||||
} finally {
|
||||
await fh.close();
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const resp = await uploadAsset();
|
||||
const maybeRestoreAssetLabel = async (uploadedAsset: {
|
||||
id?: number;
|
||||
name?: string;
|
||||
label?: string | null;
|
||||
[key: string]: any;
|
||||
}) => {
|
||||
if (!uploadedAsset.name || uploadedAsset.name === name || !uploadedAsset.id) {
|
||||
return uploadedAsset;
|
||||
}
|
||||
|
||||
console.log(`✏️ Restoring asset label to ${name}...`);
|
||||
|
||||
const updateAssetLabel = async (assetId: number) => {
|
||||
const { data } = await releaser.updateReleaseAsset({
|
||||
owner,
|
||||
repo,
|
||||
asset_id: assetId,
|
||||
name: uploadedAsset.name!,
|
||||
label: name,
|
||||
});
|
||||
return data;
|
||||
};
|
||||
|
||||
try {
|
||||
return await updateAssetLabel(uploadedAsset.id);
|
||||
} catch (error: any) {
|
||||
const errorStatus = error?.status ?? error?.response?.status;
|
||||
|
||||
if (errorStatus === 404 && releaseId !== undefined) {
|
||||
try {
|
||||
const latestAsset = await findReleaseAsset(
|
||||
(currentAsset) =>
|
||||
currentAsset.id === uploadedAsset.id || currentAsset.name === uploadedAsset.name,
|
||||
);
|
||||
if (latestAsset) {
|
||||
return await updateAssetLabel(latestAsset.id);
|
||||
}
|
||||
} catch (refreshError) {
|
||||
console.warn(`error refreshing release assets for ${name}: ${refreshError}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.warn(`error updating release asset label for ${name}: ${error}`);
|
||||
return uploadedAsset;
|
||||
}
|
||||
};
|
||||
|
||||
const handleUploadedAsset = async (resp: { status: number; data: any }) => {
|
||||
const json = resp.data;
|
||||
if (resp.status !== 201) {
|
||||
throw new Error(
|
||||
@@ -344,28 +511,39 @@ export const upload = async (
|
||||
}\n${json.message}\n${JSON.stringify(json.errors)}`,
|
||||
);
|
||||
}
|
||||
if (json.name && json.name !== name && json.id) {
|
||||
console.log(`✏️ Restoring asset label to ${name}...`);
|
||||
try {
|
||||
const { data } = await releaser.updateReleaseAsset({
|
||||
owner,
|
||||
repo,
|
||||
asset_id: json.id,
|
||||
name: json.name,
|
||||
label: name,
|
||||
});
|
||||
console.log(`✅ Uploaded ${name}`);
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.warn(`error updating release asset label for ${name}: ${error}`);
|
||||
}
|
||||
}
|
||||
const assetWithLabel = await maybeRestoreAssetLabel(json);
|
||||
console.log(`✅ Uploaded ${name}`);
|
||||
return json;
|
||||
return assetWithLabel;
|
||||
};
|
||||
|
||||
try {
|
||||
return await handleUploadedAsset(await uploadAsset());
|
||||
} catch (error: any) {
|
||||
const errorStatus = error?.status ?? error?.response?.status;
|
||||
const errorData = error?.response?.data;
|
||||
|
||||
if (isImmutableReleaseAssetUploadFailure(error)) {
|
||||
throw new Error(immutableReleaseAssetUploadMessage(name, config.input_prerelease));
|
||||
}
|
||||
|
||||
if (releaseId !== undefined && isReleaseAssetUpdateNotFound(error)) {
|
||||
try {
|
||||
const latestAsset = await findReleaseAsset((currentAsset) =>
|
||||
releaseAssetMatchesName(name, currentAsset),
|
||||
);
|
||||
if (latestAsset) {
|
||||
console.warn(
|
||||
`error updating release asset metadata for ${name}: ${error}. Matching asset is present after refresh; continuing...`,
|
||||
);
|
||||
return latestAsset;
|
||||
}
|
||||
} catch (refreshError) {
|
||||
console.warn(
|
||||
`error refreshing release assets after metadata update failure: ${refreshError}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle race conditions across concurrent workflows uploading the same asset.
|
||||
if (
|
||||
config.input_overwrite_files !== false &&
|
||||
@@ -381,26 +559,17 @@ export const upload = async (
|
||||
repo,
|
||||
release_id: releaseId,
|
||||
});
|
||||
const latestAsset = latestAssets.find(
|
||||
({ name: currentName }) => currentName == alignAssetName(name),
|
||||
const latestAsset = latestAssets.find((currentAsset) =>
|
||||
releaseAssetMatchesName(name, currentAsset),
|
||||
);
|
||||
if (latestAsset) {
|
||||
await releaser.deleteReleaseAsset({
|
||||
owner,
|
||||
repo,
|
||||
release_id: releaseId,
|
||||
asset_id: latestAsset.id,
|
||||
});
|
||||
const retryResp = await uploadAsset();
|
||||
const retryJson = retryResp.data;
|
||||
if (retryResp.status !== 201) {
|
||||
throw new Error(
|
||||
`Failed to upload release asset ${name}. received status code ${
|
||||
retryResp.status
|
||||
}\n${retryJson.message}\n${JSON.stringify(retryJson.errors)}`,
|
||||
);
|
||||
}
|
||||
console.log(`✅ Uploaded ${name}`);
|
||||
return retryJson;
|
||||
return await handleUploadedAsset(await uploadAsset());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -420,27 +589,46 @@ export const release = async (
|
||||
|
||||
const [owner, repo] = config.github_repository.split('/');
|
||||
const tag =
|
||||
config.input_tag_name ||
|
||||
normalizeTagName(config.input_tag_name) ||
|
||||
(isTag(config.github_ref) ? config.github_ref.replace('refs/tags/', '') : '');
|
||||
|
||||
const discussion_category_name = config.input_discussion_category_name;
|
||||
const generate_release_notes = config.input_generate_release_notes;
|
||||
const previous_tag_name = config.input_previous_tag;
|
||||
|
||||
if (generate_release_notes && previous_tag_name) {
|
||||
console.log(`📝 Generating release notes using previous tag ${previous_tag_name}`);
|
||||
}
|
||||
let _release: Release | undefined;
|
||||
try {
|
||||
const _release: Release | undefined = await findTagFromReleases(releaser, owner, repo, tag);
|
||||
|
||||
if (_release === undefined) {
|
||||
return await createRelease(
|
||||
tag,
|
||||
config,
|
||||
releaser,
|
||||
owner,
|
||||
repo,
|
||||
discussion_category_name,
|
||||
generate_release_notes,
|
||||
maxRetries,
|
||||
);
|
||||
_release = await findTagFromReleases(releaser, owner, repo, tag, maxRetries);
|
||||
} catch (error) {
|
||||
if (error.status === 404) {
|
||||
const diagnostic = releaseLookup404Message(owner, repo, error);
|
||||
console.log(`⚠️ ${diagnostic}`);
|
||||
throw new ReleaseAccessError(diagnostic, error);
|
||||
}
|
||||
console.log(
|
||||
`⚠️ Unexpected error fetching GitHub release for tag ${config.github_ref}: ${error}`,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (_release === undefined) {
|
||||
return await createRelease(
|
||||
tag,
|
||||
config,
|
||||
releaser,
|
||||
owner,
|
||||
repo,
|
||||
discussion_category_name,
|
||||
generate_release_notes,
|
||||
maxRetries,
|
||||
previous_tag_name,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
let existingRelease: Release = _release!;
|
||||
console.log(`Found release ${existingRelease.name} (with id=${existingRelease.id})`);
|
||||
|
||||
@@ -491,12 +679,16 @@ export const release = async (
|
||||
discussion_category_name,
|
||||
generate_release_notes,
|
||||
make_latest,
|
||||
previous_tag_name,
|
||||
});
|
||||
return {
|
||||
release: release.data,
|
||||
created: false,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof ReleaseCreationError) {
|
||||
throw error;
|
||||
}
|
||||
if (error.status !== 404) {
|
||||
console.log(
|
||||
`⚠️ Unexpected error fetching GitHub release for tag ${config.github_ref}: ${error}`,
|
||||
@@ -513,6 +705,7 @@ export const release = async (
|
||||
discussion_category_name,
|
||||
generate_release_notes,
|
||||
maxRetries,
|
||||
previous_tag_name,
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -549,6 +742,7 @@ export const finalizeRelease = async (
|
||||
repo,
|
||||
release_id: release.id,
|
||||
make_latest: config.input_make_latest,
|
||||
discussion_category_name: config.input_discussion_category_name,
|
||||
});
|
||||
|
||||
return data;
|
||||
@@ -623,14 +817,16 @@ export const listReleaseAssets = async (
|
||||
/**
|
||||
* Finds a release by tag name.
|
||||
*
|
||||
* Uses the direct getReleaseByTag API for O(1) lookup instead of iterating
|
||||
* through all releases. This also avoids GitHub's API pagination limit of
|
||||
* 10000 results which would cause failures for repositories with many releases.
|
||||
* Uses the direct getReleaseByTag API for O(1) lookup. Because GitHub does not
|
||||
* expose draft releases through that endpoint, a 404 falls back to a bounded
|
||||
* scan of recent releases and briefly retries in case the listing is not yet
|
||||
* consistent.
|
||||
*
|
||||
* @param releaser - The GitHub API wrapper for release operations
|
||||
* @param owner - The owner of the repository
|
||||
* @param repo - The name of the repository
|
||||
* @param tag - The tag name to search for
|
||||
* @param listingAttempts - The maximum number of listing attempts after a direct 404
|
||||
* @returns The release with the given tag name, or undefined if no release with that tag name is found
|
||||
*/
|
||||
export async function findTagFromReleases(
|
||||
@@ -638,18 +834,35 @@ export async function findTagFromReleases(
|
||||
owner: string,
|
||||
repo: string,
|
||||
tag: string,
|
||||
listingAttempts: number = 1,
|
||||
): Promise<Release | undefined> {
|
||||
try {
|
||||
const { data: release } = await releaser.getReleaseByTag({ owner, repo, tag });
|
||||
return release;
|
||||
} catch (error) {
|
||||
// Release not found (404) or other error - return undefined to allow creation
|
||||
if (error.status === 404) {
|
||||
return undefined;
|
||||
if (error.status !== 404) {
|
||||
throw error;
|
||||
}
|
||||
// Re-throw unexpected errors
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (listingAttempts <= 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const attempts = Math.max(1, listingAttempts);
|
||||
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
||||
const recentReleases = await recentReleasesByTag(releaser, owner, repo, tag);
|
||||
const canonicalRelease = pickCanonicalRelease(recentReleases, undefined);
|
||||
if (canonicalRelease) {
|
||||
return canonicalRelease;
|
||||
}
|
||||
|
||||
if (attempt < attempts - 1) {
|
||||
await sleep(CREATED_RELEASE_DISCOVERY_RETRY_DELAY_MS);
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const CREATED_RELEASE_DISCOVERY_RETRY_DELAY_MS = 1000;
|
||||
@@ -701,29 +914,31 @@ function pickCanonicalRelease(
|
||||
})[0];
|
||||
}
|
||||
|
||||
async function cleanupDuplicateDraftReleases(
|
||||
async function cleanupCreatedDuplicateDraftRelease(
|
||||
releaser: Releaser,
|
||||
owner: string,
|
||||
repo: string,
|
||||
tag: string,
|
||||
canonicalReleaseId: number,
|
||||
recentReleases: Release[],
|
||||
createdRelease: Release,
|
||||
): Promise<void> {
|
||||
for (const duplicate of recentReleases) {
|
||||
if (duplicate.id === canonicalReleaseId || !duplicate.draft || duplicate.assets.length > 0) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
createdRelease.id === canonicalReleaseId ||
|
||||
!createdRelease.draft ||
|
||||
createdRelease.assets.length > 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`🧹 Removing duplicate draft release ${duplicate.id} for tag ${tag}...`);
|
||||
await releaser.deleteRelease({
|
||||
owner,
|
||||
repo,
|
||||
release_id: duplicate.id,
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn(`error deleting duplicate release ${duplicate.id}: ${error}`);
|
||||
}
|
||||
try {
|
||||
console.log(`🧹 Removing duplicate draft release ${createdRelease.id} for tag ${tag}...`);
|
||||
await releaser.deleteRelease({
|
||||
owner,
|
||||
repo,
|
||||
release_id: createdRelease.id,
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn(`error deleting duplicate release ${createdRelease.id}: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -740,7 +955,7 @@ async function canonicalizeCreatedRelease(
|
||||
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
||||
let releaseByTag: Release | undefined;
|
||||
try {
|
||||
releaseByTag = await findTagFromReleases(releaser, owner, repo, tag);
|
||||
releaseByTag = await findTagFromReleases(releaser, owner, repo, tag, 0);
|
||||
} catch (error) {
|
||||
console.warn(`error reloading release for tag ${tag}: ${error}`);
|
||||
}
|
||||
@@ -760,14 +975,19 @@ async function canonicalizeCreatedRelease(
|
||||
);
|
||||
}
|
||||
|
||||
await cleanupDuplicateDraftReleases(
|
||||
releaser,
|
||||
owner,
|
||||
repo,
|
||||
tag,
|
||||
canonicalRelease.id,
|
||||
recentReleases,
|
||||
const refreshedCreatedRelease = recentReleases.find(
|
||||
(release) => release.id === createdRelease.id,
|
||||
);
|
||||
if (refreshedCreatedRelease) {
|
||||
await cleanupCreatedDuplicateDraftRelease(
|
||||
releaser,
|
||||
owner,
|
||||
repo,
|
||||
tag,
|
||||
canonicalRelease.id,
|
||||
refreshedCreatedRelease,
|
||||
);
|
||||
}
|
||||
return canonicalRelease;
|
||||
}
|
||||
|
||||
@@ -796,6 +1016,7 @@ async function createRelease(
|
||||
discussion_category_name: string | undefined,
|
||||
generate_release_notes: boolean | undefined,
|
||||
maxRetries: number,
|
||||
previous_tag_name: string | undefined,
|
||||
): Promise<ReleaseResult> {
|
||||
const tag_name = tag;
|
||||
const name = config.input_name || tag;
|
||||
@@ -822,6 +1043,7 @@ async function createRelease(
|
||||
discussion_category_name,
|
||||
generate_release_notes,
|
||||
make_latest,
|
||||
previous_tag_name,
|
||||
});
|
||||
const canonicalRelease = await canonicalizeCreatedRelease(
|
||||
releaser,
|
||||
@@ -835,12 +1057,16 @@ async function createRelease(
|
||||
release: canonicalRelease,
|
||||
created: canonicalRelease.id === createdRelease.data.id,
|
||||
};
|
||||
} catch (error) {
|
||||
} catch (error: unknown) {
|
||||
const githubError = error as {
|
||||
status?: number;
|
||||
response?: { data?: { errors?: Array<{ code?: string }> } };
|
||||
};
|
||||
// presume a race with competing matrix runs
|
||||
console.log(`⚠️ GitHub release failed with status: ${error.status}`);
|
||||
console.log(`${JSON.stringify(error.response.data)}`);
|
||||
console.log(`⚠️ GitHub release failed with status: ${githubError.status}`);
|
||||
console.log(errorMessage(error));
|
||||
|
||||
switch (error.status) {
|
||||
switch (githubError.status) {
|
||||
case 403:
|
||||
console.log(
|
||||
'Skip retry — your GitHub token/PAT does not have the required permission to create a release',
|
||||
@@ -848,12 +1074,13 @@ async function createRelease(
|
||||
throw error;
|
||||
|
||||
case 404:
|
||||
console.log('Skip retry - discussion category mismatch');
|
||||
throw error;
|
||||
const diagnostic = releaseCreation404Message(owner, repo, discussion_category_name, error);
|
||||
console.log(`Skip retry — ${diagnostic}`);
|
||||
throw new ReleaseCreationError(diagnostic, error);
|
||||
|
||||
case 422:
|
||||
// Check if this is a race condition with "already_exists" error
|
||||
const errorData = error.response?.data;
|
||||
const errorData = githubError.response?.data;
|
||||
if (errorData?.errors?.[0]?.code === 'already_exists') {
|
||||
console.log(
|
||||
'⚠️ Release already exists (race condition detected), retrying to find and update existing release...',
|
||||
|
||||
+2
-113
@@ -1,114 +1,3 @@
|
||||
import { setFailed, setOutput } from '@actions/core';
|
||||
import { getOctokit } from '@actions/github';
|
||||
import { GitHubReleaser, release, finalizeRelease, upload, listReleaseAssets } from './github';
|
||||
import { isTag, parseConfig, paths, unmatchedPatterns, uploadUrl } from './util';
|
||||
import { run } from './run';
|
||||
|
||||
import { env } from 'process';
|
||||
|
||||
async function run() {
|
||||
try {
|
||||
const config = parseConfig(env);
|
||||
if (!config.input_tag_name && !isTag(config.github_ref) && !config.input_draft) {
|
||||
throw new Error(`⚠️ GitHub Releases requires a tag`);
|
||||
}
|
||||
if (config.input_files) {
|
||||
const patterns = unmatchedPatterns(config.input_files, config.input_working_directory);
|
||||
patterns.forEach((pattern) => {
|
||||
if (config.input_fail_on_unmatched_files) {
|
||||
throw new Error(`⚠️ Pattern '${pattern}' does not match any files.`);
|
||||
} else {
|
||||
console.warn(`🤔 Pattern '${pattern}' does not match any files.`);
|
||||
}
|
||||
});
|
||||
if (patterns.length > 0 && config.input_fail_on_unmatched_files) {
|
||||
throw new Error(`⚠️ There were unmatched files`);
|
||||
}
|
||||
}
|
||||
|
||||
// const oktokit = GitHub.plugin(
|
||||
// require("@octokit/plugin-throttling"),
|
||||
// require("@octokit/plugin-retry")
|
||||
// );
|
||||
|
||||
const gh = getOctokit(config.github_token, {
|
||||
//new oktokit(
|
||||
throttle: {
|
||||
onRateLimit: (retryAfter, options) => {
|
||||
console.warn(`Request quota exhausted for request ${options.method} ${options.url}`);
|
||||
if (options.request.retryCount === 0) {
|
||||
// only retries once
|
||||
console.log(`Retrying after ${retryAfter} seconds!`);
|
||||
return true;
|
||||
}
|
||||
},
|
||||
onAbuseLimit: (retryAfter, options) => {
|
||||
// does not retry, only logs a warning
|
||||
console.warn(`Abuse detected for request ${options.method} ${options.url}`);
|
||||
},
|
||||
},
|
||||
});
|
||||
//);
|
||||
const releaser = new GitHubReleaser(gh);
|
||||
const releaseResult = await release(config, releaser);
|
||||
let rel = releaseResult.release;
|
||||
const releaseWasCreated = releaseResult.created;
|
||||
let uploadedAssetIds: Set<number> = new Set();
|
||||
if (config.input_files && config.input_files.length > 0) {
|
||||
const files = paths(config.input_files, config.input_working_directory);
|
||||
if (files.length == 0) {
|
||||
if (config.input_fail_on_unmatched_files) {
|
||||
throw new Error(`⚠️ ${config.input_files} does not include a valid file.`);
|
||||
} else {
|
||||
console.warn(`🤔 ${config.input_files} does not include a valid file.`);
|
||||
}
|
||||
}
|
||||
const currentAssets = rel.assets;
|
||||
|
||||
const uploadFile = async (path: string) => {
|
||||
const json = await upload(config, releaser, uploadUrl(rel.upload_url), path, currentAssets);
|
||||
return json ? (json.id as number) : undefined;
|
||||
};
|
||||
|
||||
let results: (number | undefined)[];
|
||||
if (!config.input_preserve_order) {
|
||||
results = await Promise.all(files.map(uploadFile));
|
||||
} else {
|
||||
results = [];
|
||||
for (const path of files) {
|
||||
results.push(await uploadFile(path));
|
||||
}
|
||||
}
|
||||
|
||||
uploadedAssetIds = new Set(results.filter((id): id is number => id !== undefined));
|
||||
}
|
||||
|
||||
console.log('Finalizing release...');
|
||||
rel = await finalizeRelease(config, releaser, rel, releaseWasCreated);
|
||||
|
||||
// Draft releases use temporary "untagged-..." URLs for assets.
|
||||
// URLs will be changed to correct ones once the release is published.
|
||||
console.log('Getting assets list...');
|
||||
{
|
||||
let assets: any[] = [];
|
||||
if (uploadedAssetIds.size > 0) {
|
||||
const updatedAssets = await listReleaseAssets(config, releaser, rel);
|
||||
assets = updatedAssets
|
||||
.filter((a) => uploadedAssetIds.has(a.id))
|
||||
.map((a) => {
|
||||
const { uploader, ...rest } = a;
|
||||
return rest;
|
||||
});
|
||||
}
|
||||
setOutput('assets', assets);
|
||||
}
|
||||
|
||||
console.log(`🎉 Release ready at ${rel.html_url}`);
|
||||
setOutput('url', rel.html_url);
|
||||
setOutput('id', rel.id.toString());
|
||||
setOutput('upload_url', rel.upload_url);
|
||||
} catch (error) {
|
||||
setFailed(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
run();
|
||||
void run();
|
||||
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
import { setFailed, setOutput } from '@actions/core';
|
||||
import { getOctokit } from '@actions/github';
|
||||
import { env } from 'process';
|
||||
import { GitHubReleaser, release, finalizeRelease, upload, listReleaseAssets } from './github';
|
||||
import { errorMessage, isTag, parseConfig, paths, unmatchedPatterns, uploadUrl } from './util';
|
||||
|
||||
export async function run(): Promise<void> {
|
||||
try {
|
||||
const config = parseConfig(env);
|
||||
if (!config.input_tag_name && !isTag(config.github_ref) && !config.input_draft) {
|
||||
throw new Error(`⚠️ GitHub Releases requires a tag`);
|
||||
}
|
||||
if (config.input_files) {
|
||||
const patterns = unmatchedPatterns(config.input_files, config.input_working_directory);
|
||||
patterns.forEach((pattern) => {
|
||||
if (config.input_fail_on_unmatched_files) {
|
||||
throw new Error(`⚠️ Pattern '${pattern}' does not match any files.`);
|
||||
} else {
|
||||
console.warn(`🤔 Pattern '${pattern}' does not match any files.`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const gh = getOctokit(config.github_token, {
|
||||
throttle: {
|
||||
onRateLimit: (retryAfter, options) => {
|
||||
console.warn(`Request quota exhausted for request ${options.method} ${options.url}`);
|
||||
if (options.request.retryCount === 0) {
|
||||
console.log(`Retrying after ${retryAfter} seconds!`);
|
||||
return true;
|
||||
}
|
||||
},
|
||||
onAbuseLimit: (_retryAfter, options) => {
|
||||
console.warn(`Abuse detected for request ${options.method} ${options.url}`);
|
||||
},
|
||||
},
|
||||
});
|
||||
const releaser = new GitHubReleaser(gh);
|
||||
const releaseResult = await release(config, releaser);
|
||||
let rel = releaseResult.release;
|
||||
const releaseWasCreated = releaseResult.created;
|
||||
let uploadedAssetIds: Set<number> = new Set();
|
||||
if (config.input_files && config.input_files.length > 0) {
|
||||
const files = paths(config.input_files, config.input_working_directory);
|
||||
if (files.length === 0) {
|
||||
if (config.input_fail_on_unmatched_files) {
|
||||
throw new Error(`⚠️ ${config.input_files} does not include a valid file.`);
|
||||
} else {
|
||||
console.warn(`🤔 ${config.input_files} does not include a valid file.`);
|
||||
}
|
||||
}
|
||||
const currentAssets = rel.assets;
|
||||
|
||||
const uploadFile = async (path: string) => {
|
||||
const json = await upload(
|
||||
config,
|
||||
releaser,
|
||||
uploadUrl(rel.upload_url),
|
||||
path,
|
||||
currentAssets,
|
||||
rel.id,
|
||||
);
|
||||
return json ? (json.id as number) : undefined;
|
||||
};
|
||||
|
||||
let results: (number | undefined)[];
|
||||
if (!config.input_preserve_order) {
|
||||
results = await Promise.all(files.map(uploadFile));
|
||||
} else {
|
||||
results = [];
|
||||
for (const path of files) {
|
||||
results.push(await uploadFile(path));
|
||||
}
|
||||
}
|
||||
|
||||
uploadedAssetIds = new Set(results.filter((id): id is number => id !== undefined));
|
||||
}
|
||||
|
||||
console.log('Finalizing release...');
|
||||
rel = await finalizeRelease(config, releaser, rel, releaseWasCreated);
|
||||
|
||||
// Draft releases use temporary "untagged-..." URLs for assets.
|
||||
// URLs will be changed to correct ones once the release is published.
|
||||
console.log('Getting assets list...');
|
||||
let assets: any[] = [];
|
||||
if (uploadedAssetIds.size > 0) {
|
||||
const updatedAssets = await listReleaseAssets(config, releaser, rel);
|
||||
assets = updatedAssets
|
||||
.filter((asset) => uploadedAssetIds.has(asset.id))
|
||||
.map((asset) => {
|
||||
const { uploader, ...rest } = asset;
|
||||
return rest;
|
||||
});
|
||||
}
|
||||
setOutput('assets', assets);
|
||||
|
||||
console.log(`🎉 Release ready at ${rel.html_url}`);
|
||||
setOutput('url', rel.html_url);
|
||||
setOutput('id', rel.id.toString());
|
||||
setOutput('upload_url', rel.upload_url);
|
||||
} catch (error: unknown) {
|
||||
setFailed(errorMessage(error));
|
||||
}
|
||||
}
|
||||
+70
-6
@@ -1,5 +1,6 @@
|
||||
import * as glob from 'glob';
|
||||
import { statSync, readFileSync } from 'fs';
|
||||
import { homedir } from 'os';
|
||||
import * as pathLib from 'path';
|
||||
|
||||
export interface Config {
|
||||
@@ -22,10 +23,29 @@ export interface Config {
|
||||
input_target_commitish?: string;
|
||||
input_discussion_category_name?: string;
|
||||
input_generate_release_notes?: boolean;
|
||||
input_previous_tag?: string;
|
||||
input_append_body?: boolean;
|
||||
input_make_latest: 'true' | 'false' | 'legacy' | undefined;
|
||||
}
|
||||
|
||||
export const errorMessage = (error: unknown): string => {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
if (
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
'message' in error &&
|
||||
typeof error.message === 'string'
|
||||
) {
|
||||
return error.message;
|
||||
}
|
||||
if (error === null || error === undefined) {
|
||||
return 'Unknown error';
|
||||
}
|
||||
return String(error);
|
||||
};
|
||||
|
||||
export const uploadUrl = (url: string): string => {
|
||||
const templateMarkerPos = url.indexOf('{');
|
||||
if (templateMarkerPos > -1) {
|
||||
@@ -84,13 +104,21 @@ export const parseInputFiles = (files: string): string[] => {
|
||||
.filter((pat) => pat.trim() !== '');
|
||||
};
|
||||
|
||||
const parseToken = (env: Env): string => {
|
||||
const inputToken = env.INPUT_TOKEN?.trim();
|
||||
if (inputToken) {
|
||||
return inputToken;
|
||||
}
|
||||
return env.GITHUB_TOKEN?.trim() || '';
|
||||
};
|
||||
|
||||
export const parseConfig = (env: Env): Config => {
|
||||
return {
|
||||
github_token: env.GITHUB_TOKEN || env.INPUT_TOKEN || '',
|
||||
github_token: parseToken(env),
|
||||
github_ref: env.GITHUB_REF || '',
|
||||
github_repository: env.INPUT_REPOSITORY || env.GITHUB_REPOSITORY || '',
|
||||
input_name: env.INPUT_NAME,
|
||||
input_tag_name: env.INPUT_TAG_NAME?.trim(),
|
||||
input_tag_name: normalizeTagName(env.INPUT_TAG_NAME?.trim()),
|
||||
input_body: env.INPUT_BODY,
|
||||
input_body_path: env.INPUT_BODY_PATH,
|
||||
input_files: parseInputFiles(env.INPUT_FILES || ''),
|
||||
@@ -105,6 +133,7 @@ export const parseConfig = (env: Env): Config => {
|
||||
input_target_commitish: env.INPUT_TARGET_COMMITISH || undefined,
|
||||
input_discussion_category_name: env.INPUT_DISCUSSION_CATEGORY_NAME || undefined,
|
||||
input_generate_release_notes: env.INPUT_GENERATE_RELEASE_NOTES == 'true',
|
||||
input_previous_tag: env.INPUT_PREVIOUS_TAG?.trim() || undefined,
|
||||
input_append_body: env.INPUT_APPEND_BODY == 'true',
|
||||
input_make_latest: parseMakeLatest(env.INPUT_MAKE_LATEST),
|
||||
};
|
||||
@@ -117,11 +146,39 @@ const parseMakeLatest = (value: string | undefined): 'true' | 'false' | 'legacy'
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const normalizeGlobPattern = (
|
||||
pattern: string,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): string => {
|
||||
if (platform === 'win32') {
|
||||
return pattern.replace(/\\/g, '/');
|
||||
}
|
||||
return pattern;
|
||||
};
|
||||
|
||||
export const expandHomePattern = (pattern: string, homeDirectory: string = homedir()): string => {
|
||||
if (pattern === '~') {
|
||||
return homeDirectory;
|
||||
}
|
||||
if (pattern.startsWith('~/') || pattern.startsWith('~\\')) {
|
||||
return pathLib.join(homeDirectory, pattern.slice(2));
|
||||
}
|
||||
return pattern;
|
||||
};
|
||||
|
||||
export const normalizeFilePattern = (
|
||||
pattern: string,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
homeDirectory: string = homedir(),
|
||||
): string => {
|
||||
return normalizeGlobPattern(expandHomePattern(pattern, homeDirectory), platform);
|
||||
};
|
||||
|
||||
export const paths = (patterns: string[], cwd?: string): string[] => {
|
||||
return patterns.reduce((acc: string[], pattern: string): string[] => {
|
||||
const matches = glob.sync(pattern, { cwd, dot: true, absolute: false });
|
||||
const matches = glob.sync(normalizeFilePattern(pattern), { cwd, dot: true, absolute: false });
|
||||
const resolved = matches
|
||||
.map((p) => (cwd ? pathLib.join(cwd, p) : p))
|
||||
.map((p) => (cwd && !pathLib.isAbsolute(p) ? pathLib.join(cwd, p) : p))
|
||||
.filter((p) => {
|
||||
try {
|
||||
return statSync(p).isFile();
|
||||
@@ -135,10 +192,10 @@ export const paths = (patterns: string[], cwd?: string): string[] => {
|
||||
|
||||
export const unmatchedPatterns = (patterns: string[], cwd?: string): string[] => {
|
||||
return patterns.reduce((acc: string[], pattern: string): string[] => {
|
||||
const matches = glob.sync(pattern, { cwd, dot: true, absolute: false });
|
||||
const matches = glob.sync(normalizeFilePattern(pattern), { cwd, dot: true, absolute: false });
|
||||
const files = matches.filter((p) => {
|
||||
try {
|
||||
const full = cwd ? pathLib.join(cwd, p) : p;
|
||||
const full = cwd && !pathLib.isAbsolute(p) ? pathLib.join(cwd, p) : p;
|
||||
return statSync(full).isFile();
|
||||
} catch {
|
||||
return false;
|
||||
@@ -152,6 +209,13 @@ export const isTag = (ref: string): boolean => {
|
||||
return ref.startsWith('refs/tags/');
|
||||
};
|
||||
|
||||
export const normalizeTagName = (tag: string | undefined): string | undefined => {
|
||||
if (!tag) {
|
||||
return tag;
|
||||
}
|
||||
return isTag(tag) ? tag.replace('refs/tags/', '') : tag;
|
||||
};
|
||||
|
||||
export const alignAssetName = (assetName: string): string => {
|
||||
return assetName.replace(/ /g, '.');
|
||||
};
|
||||
|
||||
+8
-1
@@ -4,7 +4,14 @@ export default defineConfig({
|
||||
test: {
|
||||
environment: 'node',
|
||||
coverage: {
|
||||
reporter: ['text', 'lcov'],
|
||||
reporter: ['text', 'json-summary', 'lcov'],
|
||||
include: ['src/**/*.ts'],
|
||||
thresholds: {
|
||||
statements: 88,
|
||||
branches: 83,
|
||||
functions: 86,
|
||||
lines: 88,
|
||||
},
|
||||
},
|
||||
include: ['__tests__/**/*.ts'],
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user