mirror of
https://github.com/softprops/action-gh-release.git
synced 2026-03-15 09:20:54 -04:00
Compare commits
157 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1853d73993 | ||
|
|
e8dbf3cc4a | ||
|
|
37f7a20824 | ||
|
|
45211baa90 | ||
|
|
21ae1a1eb2 | ||
|
|
26c9a934b1 | ||
|
|
abb4370aef | ||
|
|
ff689a6881 | ||
|
|
0a28836784 | ||
|
|
bafaa2d7ac | ||
|
|
b36466e122 | ||
|
|
b25b93d384 | ||
|
|
7a0ff5e07a | ||
|
|
488ac715ff | ||
|
|
52847653ee | ||
|
|
4aadb0df8b | ||
|
|
ef43a3125e | ||
|
|
ab416a1836 | ||
|
|
71d29a04ae | ||
|
|
320a0beb24 | ||
|
|
b3b644b91a | ||
|
|
3074e62a34 | ||
|
|
d015dc32db | ||
|
|
e320ecb65b | ||
|
|
dd0610403c | ||
|
|
b01abe66f7 | ||
|
|
e798e6a1ed | ||
|
|
b8de2196f6 | ||
|
|
7458a2c744 | ||
|
|
78237c54eb | ||
|
|
bf563aeb6d | ||
|
|
026d617849 | ||
|
|
5122b4edc9 | ||
|
|
76521a806e | ||
|
|
60cfd9a691 | ||
|
|
69bd94bb12 | ||
|
|
8dca0e43c6 | ||
|
|
a06a81a03e | ||
|
|
7da8983734 | ||
|
|
87973286a4 | ||
|
|
1bfc62a71b | ||
|
|
5be0e66d93 | ||
|
|
af658b4d5d | ||
|
|
237aaccf71 | ||
|
|
00362bea6f | ||
|
|
0adea5aa98 | ||
|
|
aa05f9d779 | ||
|
|
bbaccb3a0c | ||
|
|
50fda3f773 | ||
|
|
5434409c2b | ||
|
|
6da8fa9354 | ||
|
|
f38efdea4c | ||
|
|
cec1a1113b | ||
|
|
aec2ec56f9 | ||
|
|
4db716b167 | ||
|
|
14820f2cee | ||
|
|
62c96d0c4e | ||
|
|
7dc9b8ac0f | ||
|
|
0f0e0b98e9 | ||
|
|
97d42c1b50 | ||
|
|
19cd0bcd2b | ||
|
|
5d1b0b1164 | ||
|
|
f6021cf9a4 | ||
|
|
6cbd405e2c | ||
|
|
fbadcc90e8 | ||
|
|
4a840061c4 | ||
|
|
7191749478 | ||
|
|
126b1e7093 | ||
|
|
f82d31e53e | ||
|
|
f2352b97da | ||
|
|
f0b3259de2 | ||
|
|
f37a2f9143 | ||
|
|
db560141c6 | ||
|
|
40521a2029 | ||
|
|
c8d8e91662 | ||
|
|
605f567f95 | ||
|
|
5822334cb4 | ||
|
|
72f2c25fcb | ||
|
|
552dc5524b | ||
|
|
f3cad8bcbf | ||
|
|
07a2257003 | ||
|
|
d5382d3e6f | ||
|
|
a0e2122208 | ||
|
|
8836085300 | ||
|
|
86463358d8 | ||
|
|
46b284799f | ||
|
|
37fd9d0351 | ||
|
|
39ba0b9d81 | ||
|
|
da05d55257 | ||
|
|
6b18c2f260 | ||
|
|
e2b105c98e | ||
|
|
e707470492 | ||
|
|
36833a1c71 | ||
|
|
8bb7207875 | ||
|
|
93bb5ff43e | ||
|
|
581b12c87f | ||
|
|
b540ad2354 | ||
|
|
ac224e9440 | ||
|
|
ab50eebb64 | ||
|
|
670eb2f21a | ||
|
|
4b4d743a9b | ||
|
|
daffc988f2 | ||
|
|
ffb524fa90 | ||
|
|
a921960380 | ||
|
|
51cfd90a6d | ||
|
|
431939b21f | ||
|
|
3792cde6c4 | ||
|
|
1fd6f3f2b1 | ||
|
|
7e44a1f6a7 | ||
|
|
67b6c38432 | ||
|
|
c43d7637b9 | ||
|
|
c95fe14893 | ||
|
|
deddb09c64 | ||
|
|
33fcd69d45 | ||
|
|
01050bd877 | ||
|
|
92dffe6c28 | ||
|
|
7b4da11513 | ||
|
|
64f1fa19ef | ||
|
|
9e35a64dfd | ||
|
|
92bc83c421 | ||
|
|
09f0e37203 | ||
|
|
f138a07409 | ||
|
|
39a5683760 | ||
|
|
62368ac126 | ||
|
|
181b5bc7bb | ||
|
|
3284404123 | ||
|
|
01570a1f39 | ||
|
|
d5f028c822 | ||
|
|
98daca21d1 | ||
|
|
b019a5bbb6 | ||
|
|
73e673b2de | ||
|
|
e7a8f85e1c | ||
|
|
04afa1392e | ||
|
|
894468a03c | ||
|
|
3bd23aa9ec | ||
|
|
21eb2f9554 | ||
|
|
cd8b57e572 | ||
|
|
820a5adc43 | ||
|
|
9d04f90cd8 | ||
|
|
aaf1d5f6d5 | ||
|
|
7d33a7ecc3 | ||
|
|
4ab8bf77de | ||
|
|
0a7d9cdbe4 | ||
|
|
aec195fdab | ||
|
|
b2cf127d24 | ||
|
|
285c2f0c13 | ||
|
|
c7857c88c0 | ||
|
|
42b6106688 | ||
|
|
79721680df | ||
|
|
33892d43f0 | ||
|
|
3362207ba1 | ||
|
|
24a040e564 | ||
|
|
9a28f2423f | ||
|
|
e5aff6f50f | ||
|
|
69d83aede9 | ||
|
|
0ab9029cac | ||
|
|
d1798aa3dc |
97
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
97
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
@@ -0,0 +1,97 @@
|
||||
name: Bug report
|
||||
description: Report a bug or regression in action-gh-release
|
||||
title: "[Bug]: "
|
||||
labels:
|
||||
- bug
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Before filing:
|
||||
- confirm the problem still reproduces on the latest release or `master`
|
||||
- search existing issues for the same behavior
|
||||
- if the original repository is private, include a minimal public repro, a sanitized workflow snippet, or exact redacted steps a maintainer can follow
|
||||
- type: checkboxes
|
||||
id: checks
|
||||
attributes:
|
||||
label: Pre-flight checks
|
||||
options:
|
||||
- label: I searched existing issues and did not find a duplicate
|
||||
required: true
|
||||
- label: I reproduced this with the latest released version or current `master`
|
||||
required: true
|
||||
- label: I included a reproducible example or a sanitized/redacted reproduction path if the original repository is private
|
||||
required: true
|
||||
- type: input
|
||||
id: action_version
|
||||
attributes:
|
||||
label: action-gh-release version
|
||||
description: Tag, SHA, or ref used in your workflow
|
||||
placeholder: v2.5.2
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: runner
|
||||
attributes:
|
||||
label: Runner operating system
|
||||
options:
|
||||
- ubuntu-latest
|
||||
- windows-latest
|
||||
- macos-latest
|
||||
- other
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: target_repository
|
||||
attributes:
|
||||
label: Release target repository
|
||||
description: Fill this in if you set the `repository:` input
|
||||
placeholder: owner/repo
|
||||
- type: input
|
||||
id: repro_reference
|
||||
attributes:
|
||||
label: Reproduction repo, gist, or artifact
|
||||
description: Link a minimal repro repository, gist, run URL, or other shareable artifact if you have one
|
||||
placeholder: https://github.com/owner/repro-repo
|
||||
- type: textarea
|
||||
id: workflow
|
||||
attributes:
|
||||
label: Workflow snippet
|
||||
description: Include the relevant `uses:` step and inputs. If the original repo is private, paste a sanitized version here.
|
||||
render: yaml
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: Expected behavior
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: actual
|
||||
attributes:
|
||||
label: Actual behavior
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: reproduce
|
||||
attributes:
|
||||
label: Steps to reproduce
|
||||
description: Include tags, matrix/concurrency details, and any repo rules involved. If the original repo is private, describe the smallest setup a maintainer can recreate locally or in a throwaway repo.
|
||||
placeholder: |
|
||||
1. Trigger workflow with ...
|
||||
2. Action creates ...
|
||||
3. Action fails with ...
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Relevant logs
|
||||
description: Paste the relevant error output or run URL
|
||||
render: shell
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: additional
|
||||
attributes:
|
||||
label: Additional context
|
||||
description: Any extra environment, token, ruleset, or asset details
|
||||
50
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
50
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
@@ -0,0 +1,50 @@
|
||||
name: Feature request
|
||||
description: Propose an enhancement or new capability for action-gh-release
|
||||
title: "[Feature]: "
|
||||
labels:
|
||||
- enhancement
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Use this template for new capabilities, behavior changes, or ergonomics improvements.
|
||||
If you are reporting something broken, use the bug report template instead.
|
||||
- type: checkboxes
|
||||
id: checks
|
||||
attributes:
|
||||
label: Pre-flight checks
|
||||
options:
|
||||
- label: I searched existing issues and did not find a duplicate request
|
||||
required: true
|
||||
- label: This is not a bug report for existing behavior
|
||||
required: true
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: Problem to solve
|
||||
description: What workflow pain point or gap are you trying to address?
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: proposal
|
||||
attributes:
|
||||
label: Proposed solution
|
||||
description: Describe the behavior, input, or output you want
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: workflow
|
||||
attributes:
|
||||
label: Example workflow snippet
|
||||
description: Show how you would expect to use this
|
||||
render: yaml
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: Alternatives considered
|
||||
description: Workarounds or other approaches you evaluated
|
||||
- type: textarea
|
||||
id: impact
|
||||
attributes:
|
||||
label: Why this belongs in action-gh-release
|
||||
description: Explain the user impact or why this should live in the action rather than in workflow glue
|
||||
11
.github/dependabot.yml
vendored
11
.github/dependabot.yml
vendored
@@ -4,15 +4,26 @@ updates:
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: weekly
|
||||
groups:
|
||||
npm:
|
||||
patterns:
|
||||
- "*"
|
||||
ignore:
|
||||
- dependency-name: node-fetch
|
||||
versions:
|
||||
- ">=3.0.0"
|
||||
- dependency-name: "@types/node"
|
||||
versions:
|
||||
- ">=22.0.0"
|
||||
commit-message:
|
||||
prefix: "chore(deps)"
|
||||
- package-ecosystem: github-actions
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: weekly
|
||||
groups:
|
||||
github-actions:
|
||||
patterns:
|
||||
- "*"
|
||||
commit-message:
|
||||
prefix: "chore(deps)"
|
||||
|
||||
4
.github/workflows/main.yml
vendored
4
.github/workflows/main.yml
vendored
@@ -8,9 +8,9 @@ jobs:
|
||||
build:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
|
||||
|
||||
- uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4
|
||||
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6
|
||||
with:
|
||||
node-version-file: ".tool-versions"
|
||||
cache: "npm"
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -2,3 +2,4 @@ __tests__/runner/*
|
||||
# actions requires a node_modules dir https://github.com/actions/toolkit/blob/master/docs/javascript-action.md#publish-a-releasesv1-action
|
||||
# but its recommended not to check these in https://github.com/actions/toolkit/blob/master/docs/action-versioning.md#recommendations
|
||||
node_modules
|
||||
coverage
|
||||
|
||||
16
.prettierignore
Normal file
16
.prettierignore
Normal file
@@ -0,0 +1,16 @@
|
||||
# Build outputs
|
||||
dist/
|
||||
lib/
|
||||
coverage/
|
||||
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Misc
|
||||
.github/
|
||||
*.log
|
||||
.DS_Store
|
||||
__tests__/release.txt
|
||||
|
||||
# Package files
|
||||
package-lock.json
|
||||
11
.prettierrc.js
Normal file
11
.prettierrc.js
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* @type {import('prettier').Config}
|
||||
*/
|
||||
module.exports = {
|
||||
trailingComma: 'all',
|
||||
tabWidth: 2,
|
||||
semi: true,
|
||||
singleQuote: true,
|
||||
printWidth: 100,
|
||||
bracketSpacing: true,
|
||||
};
|
||||
@@ -1 +1 @@
|
||||
nodejs 20.15.1
|
||||
nodejs 24.11.0
|
||||
|
||||
229
CHANGELOG.md
229
CHANGELOG.md
@@ -1,3 +1,212 @@
|
||||
## 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.
|
||||
It fixes `#705`, fixes `#708`, fixes `#740`, fixes `#741`, and fixes `#722`.
|
||||
Regression testing covers the shared-tag race, prerelease event behavior, dotfile asset labels,
|
||||
same-filename concurrent uploads, and blocked-tag cleanup behavior.
|
||||
|
||||
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: canonicalize releases after concurrent create by @chenrui333 in https://github.com/softprops/action-gh-release/pull/746
|
||||
* fix: preserve prereleased events for prereleases by @chenrui333 in https://github.com/softprops/action-gh-release/pull/748
|
||||
* fix: restore dotfile asset labels by @chenrui333 in https://github.com/softprops/action-gh-release/pull/749
|
||||
* fix: handle upload already_exists races across workflows by @api2062 in https://github.com/softprops/action-gh-release/pull/745
|
||||
* fix: clean up orphan drafts when tag creation is blocked by @chenrui333 in https://github.com/softprops/action-gh-release/pull/750
|
||||
|
||||
## 2.5.1
|
||||
|
||||
`2.5.1` is a patch release focused on regressions introduced in `2.5.0` and on release lookup reliability.
|
||||
It fixes `#713`, addresses `#703`, and fixes `#724`. Regression testing shows that
|
||||
current `master` no longer reproduces the finalize-race behavior reported in `#704` and `#709`.
|
||||
|
||||
## What's Changed
|
||||
|
||||
### Bug fixes 🐛
|
||||
|
||||
* fix: fetch correct asset URL after finalization; test; some refactoring by @pzhlkj6612 in https://github.com/softprops/action-gh-release/pull/738
|
||||
* fix: release marked as 'latest' despite make_latest: false by @Boshen in https://github.com/softprops/action-gh-release/pull/715
|
||||
* fix: use getReleaseByTag API instead of iterating all releases by @kim-em in https://github.com/softprops/action-gh-release/pull/725
|
||||
|
||||
### Other Changes 🔄
|
||||
|
||||
* dependency updates, including the ESM/runtime compatibility refresh in https://github.com/softprops/action-gh-release/pull/731
|
||||
|
||||
## 2.5.0
|
||||
|
||||
## What's Changed
|
||||
|
||||
### Exciting New Features 🎉
|
||||
|
||||
* feat: mark release as draft until all artifacts are uploaded by @dumbmoron in https://github.com/softprops/action-gh-release/pull/692
|
||||
|
||||
### Other Changes 🔄
|
||||
|
||||
* dependency updates
|
||||
|
||||
## 2.4.2
|
||||
|
||||
## What's Changed
|
||||
|
||||
### Exciting New Features 🎉
|
||||
|
||||
* feat: Ensure generated release notes cannot be over 125000 characters by @BeryJu in https://github.com/softprops/action-gh-release/pull/684
|
||||
|
||||
### Other Changes 🔄
|
||||
|
||||
* dependency updates
|
||||
|
||||
## 2.4.1
|
||||
|
||||
## What's Changed
|
||||
|
||||
### Other Changes 🔄
|
||||
|
||||
* fix(util): support brace expansion globs containing commas in parseInputFiles by @Copilot in https://github.com/softprops/action-gh-release/pull/672
|
||||
* fix: gracefully fallback to body when body_path cannot be read by @Copilot in https://github.com/softprops/action-gh-release/pull/671
|
||||
|
||||
## 2.4.0
|
||||
|
||||
## What's Changed
|
||||
|
||||
### Exciting New Features 🎉
|
||||
|
||||
* feat(action): respect working_directory for files globs by @stephenway in https://github.com/softprops/action-gh-release/pull/667
|
||||
|
||||
## 2.3.4
|
||||
|
||||
## What's Changed
|
||||
|
||||
### Bug fixes 🐛
|
||||
|
||||
* fix(action): handle 422 already_exists race condition by @stephenway in https://github.com/softprops/action-gh-release/pull/665
|
||||
|
||||
### Other Changes 🔄
|
||||
|
||||
- dependency updates
|
||||
|
||||
## 2.3.3
|
||||
|
||||
## What's Changed
|
||||
|
||||
### Exciting New Features 🎉
|
||||
|
||||
* feat: add input option `overwrite_files` by @asfernandes in https://github.com/softprops/action-gh-release/pull/343
|
||||
|
||||
### Other Changes 🔄
|
||||
|
||||
- dependency updates
|
||||
|
||||
## 2.3.2
|
||||
|
||||
* fix: revert fs `readableWebStream` change
|
||||
|
||||
## 2.3.1
|
||||
|
||||
### Bug fixes 🐛
|
||||
|
||||
* fix: fix file closing issue by @WailGree in https://github.com/softprops/action-gh-release/pull/629
|
||||
|
||||
## 2.3.0
|
||||
|
||||
* Migrate from jest to vitest
|
||||
* Replace `mime` with `mime-types`
|
||||
* Bump to use node 24
|
||||
* Dependency updates
|
||||
|
||||
## 2.2.2
|
||||
|
||||
## What's Changed
|
||||
|
||||
### Bug fixes 🐛
|
||||
|
||||
* fix: updating release draft status from true to false by @galargh in https://github.com/softprops/action-gh-release/pull/316
|
||||
|
||||
### Other Changes 🔄
|
||||
|
||||
* chore: simplify ref_type test by @steinybot in https://github.com/softprops/action-gh-release/pull/598
|
||||
* fix(docs): clarify the default for tag_name by @muzimuzhi in https://github.com/softprops/action-gh-release/pull/599
|
||||
* test(release): add unit tests when searching for a release by @rwaskiewicz in https://github.com/softprops/action-gh-release/pull/603
|
||||
* dependency updates
|
||||
|
||||
## 2.2.1
|
||||
|
||||
## What's Changed
|
||||
|
||||
### Bug fixes 🐛
|
||||
|
||||
* fix: big file uploads by @xen0n in https://github.com/softprops/action-gh-release/pull/562
|
||||
|
||||
### Other Changes 🔄
|
||||
* chore(deps): bump @types/node from 22.10.1 to 22.10.2 by @dependabot in https://github.com/softprops/action-gh-release/pull/559
|
||||
* chore(deps): bump @types/node from 22.10.2 to 22.10.5 by @dependabot in https://github.com/softprops/action-gh-release/pull/569
|
||||
* chore: update error and warning messages for not matching files in files field by @ytimocin in https://github.com/softprops/action-gh-release/pull/568
|
||||
|
||||
## 2.2.0
|
||||
|
||||
## What's Changed
|
||||
|
||||
### Exciting New Features 🎉
|
||||
|
||||
* feat: read the release assets asynchronously by @xen0n in https://github.com/softprops/action-gh-release/pull/552
|
||||
|
||||
### Bug fixes 🐛
|
||||
|
||||
* fix(docs): clarify the default for tag_name by @alexeagle in https://github.com/softprops/action-gh-release/pull/544
|
||||
|
||||
### Other Changes 🔄
|
||||
|
||||
* chore(deps): bump typescript from 5.6.3 to 5.7.2 by @dependabot in https://github.com/softprops/action-gh-release/pull/548
|
||||
* chore(deps): bump @types/node from 22.9.0 to 22.9.4 by @dependabot in https://github.com/softprops/action-gh-release/pull/547
|
||||
* chore(deps): bump cross-spawn from 7.0.3 to 7.0.6 by @dependabot in https://github.com/softprops/action-gh-release/pull/545
|
||||
* chore(deps): bump @vercel/ncc from 0.38.2 to 0.38.3 by @dependabot in https://github.com/softprops/action-gh-release/pull/543
|
||||
* chore(deps): bump prettier from 3.3.3 to 3.4.1 by @dependabot in https://github.com/softprops/action-gh-release/pull/550
|
||||
* chore(deps): bump @types/node from 22.9.4 to 22.10.1 by @dependabot in https://github.com/softprops/action-gh-release/pull/551
|
||||
* chore(deps): bump prettier from 3.4.1 to 3.4.2 by @dependabot in https://github.com/softprops/action-gh-release/pull/554
|
||||
|
||||
## 2.1.0
|
||||
|
||||
## What's Changed
|
||||
|
||||
### Exciting New Features 🎉
|
||||
* feat: add support for release assets with multiple spaces within the name by @dukhine in https://github.com/softprops/action-gh-release/pull/518
|
||||
* feat: preserve upload order by @richarddd in https://github.com/softprops/action-gh-release/pull/500
|
||||
|
||||
### Other Changes 🔄
|
||||
* chore(deps): bump @types/node from 22.8.2 to 22.8.7 by @dependabot in https://github.com/softprops/action-gh-release/pull/539
|
||||
|
||||
## 2.0.9
|
||||
|
||||
- maintenance release with updated dependencies
|
||||
|
||||
## 2.0.8
|
||||
|
||||
### Other Changes 🔄
|
||||
@@ -54,7 +263,7 @@
|
||||
|
||||
## 2.0.0
|
||||
|
||||
- `2.0.0`!? this release corrects a disjunction between git tag versions used in the marketplace and versions list this file. Previous versions should have really been 1.\*. Going forward this should be better aligned.
|
||||
- `2.0.0`!? this release corrects a disjunction between git tag versions used in the marketplace and the versions listed in this file. Previous versions should have really been 1.\*. Going forward this should be better aligned.
|
||||
- Upgrade action.yml declaration to node20 to address deprecations
|
||||
|
||||
## 0.1.15
|
||||
@@ -65,7 +274,7 @@
|
||||
|
||||
## 0.1.14
|
||||
|
||||
- provides an new workflow input option `generate_release_notes` which when set to true will automatically generate release notes for you based on GitHub activity [#179](https://github.com/softprops/action-gh-release/pull/179). Please see the [GitHub docs for this feature](https://docs.github.com/en/repositories/releasing-projects-on-github/automatically-generated-release-notes) for more information
|
||||
- provides a new workflow input option `generate_release_notes` which when set to true will automatically generate release notes for you based on GitHub activity [#179](https://github.com/softprops/action-gh-release/pull/179). Please see the [GitHub docs for this feature](https://docs.github.com/en/repositories/releasing-projects-on-github/automatically-generated-release-notes) for more information
|
||||
|
||||
## 0.1.13
|
||||
|
||||
@@ -73,7 +282,7 @@
|
||||
|
||||
## 0.1.12
|
||||
|
||||
- fix bug leading to empty strings subsituted for inputs users don't provide breaking api calls [#144](https://github.com/softprops/action-gh-release/pull/144)
|
||||
- fix bug leading to empty strings substituted for inputs users don't provide breaking api calls [#144](https://github.com/softprops/action-gh-release/pull/144)
|
||||
|
||||
## 0.1.11
|
||||
|
||||
@@ -90,7 +299,7 @@
|
||||
## 0.1.8
|
||||
|
||||
- address recent warnings in assert upload api as well as introduce asset upload overrides, allowing for multiple runs for the same release with the same named asserts [#134](https://github.com/softprops/action-gh-release/pull/134)
|
||||
- fix backwards compatibility with `GITHUB_TOKEN` resolution. `GITHUB_TOKEN` is no resolved first from an env varibale and then from and input [#133](https://github.com/softprops/action-gh-release/pull/133)
|
||||
- fix backwards compatibility with `GITHUB_TOKEN` resolution. `GITHUB_TOKEN` is now resolved first from an env variable and then from an input [#133](https://github.com/softprops/action-gh-release/pull/133)
|
||||
- trim white space in provided `tag_name` [#130](https://github.com/softprops/action-gh-release/pull/130)
|
||||
|
||||
## 0.1.7
|
||||
@@ -103,14 +312,14 @@
|
||||
|
||||
This is a release catch up have a hiatus. Future releases will happen more frequently
|
||||
|
||||
- Add 'fail_on_unmatched_files' input, useful for catching cases were your `files` input does not actually match what you expect [#55](https://github.com/softprops/action-gh-release/pull/55)
|
||||
- Add 'fail_on_unmatched_files' input, useful for catching cases where your `files` input does not actually match what you expect [#55](https://github.com/softprops/action-gh-release/pull/55)
|
||||
- Add `repository` input, useful for creating a release in an external repository [#61](https://github.com/softprops/action-gh-release/pull/61)
|
||||
- Add release `id` to outputs, useful for refering to release in workflow steps following the step that uses this action [#60](https://github.com/softprops/action-gh-release/pull/60)
|
||||
- Add release `id` to outputs, useful for referring to release in workflow steps following the step that uses this action [#60](https://github.com/softprops/action-gh-release/pull/60)
|
||||
- Add `upload_url` as action output, useful for managing uploads separately [#75](https://github.com/softprops/action-gh-release/pull/75)
|
||||
- Support custom `target_commitish` value, useful to customize the default [#76](https://github.com/softprops/action-gh-release/pull/76)
|
||||
- fix `body_path` input first then fall back on `body` input. this was the originally documented precedence but was implemened the the opposite order! [#85](https://github.com/softprops/action-gh-release/pull/85)
|
||||
- fix `body_path` input first then fall back on `body` input. This was the originally documented precedence but was implemented in the opposite order! [#85](https://github.com/softprops/action-gh-release/pull/85)
|
||||
- Retain original release info if the keys are not set, useful for filling in blanks for a release you've already started separately [#109](https://github.com/softprops/action-gh-release/pull/109)
|
||||
- Limit number of times github api request to create a release is retried, useful for avoiding eating up your rate limit and action minutes do to either an invalid token or other circumstance causing the api call to fail [#111](https://github.com/softprops/action-gh-release/pull/111)
|
||||
- Limit number of times github api request to create a release is retried, useful for avoiding eating up your rate limit and action minutes due to either an invalid token or other circumstance causing the api call to fail [#111](https://github.com/softprops/action-gh-release/pull/111)
|
||||
|
||||
## 0.1.5
|
||||
|
||||
@@ -120,7 +329,7 @@ This is a release catch up have a hiatus. Future releases will happen more frequ
|
||||
|
||||
- Added support for updating releases body [#36](https://github.com/softprops/action-gh-release/pull/36)
|
||||
- Steps can now access the url of releases with the `url` output of this Action [#28](https://github.com/softprops/action-gh-release/pull/28)
|
||||
- Added basic GitHub API retry support to manage API turbulance [#26](https://github.com/softprops/action-gh-release/pull/26)
|
||||
- Added basic GitHub API retry support to manage API turbulence [#26](https://github.com/softprops/action-gh-release/pull/26)
|
||||
|
||||
## 0.1.3
|
||||
|
||||
@@ -135,7 +344,7 @@ This is now fixed.
|
||||
|
||||
- Add support for newline-delimited asset list [#18](https://github.com/softprops/action-gh-release/pull/18)
|
||||
|
||||
GitHub actions inputs don't inherently support lists of things and one might like to append a list of files to include in a release. Previously this was possible using a comma-delimited list of asset path patterns to upload. You can now provide these as a newline delimieted list for better readability
|
||||
GitHub actions inputs don't inherently support lists of things and one might like to append a list of files to include in a release. Previously this was possible using a comma-delimited list of asset path patterns to upload. You can now provide these as a newline delimited list for better readability
|
||||
|
||||
```yaml
|
||||
- name: Release
|
||||
|
||||
44
README.md
44
README.md
@@ -51,10 +51,10 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
- name: Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
if: github.ref_type == 'tag'
|
||||
```
|
||||
|
||||
You can also use push config tag filter
|
||||
@@ -72,7 +72,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
- name: Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
```
|
||||
@@ -99,14 +99,14 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
- name: Build
|
||||
run: echo ${{ github.sha }} > Release.txt
|
||||
- name: Test
|
||||
run: cat Release.txt
|
||||
- name: Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
if: github.ref_type == 'tag'
|
||||
with:
|
||||
files: Release.txt
|
||||
```
|
||||
@@ -123,14 +123,14 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
- name: Build
|
||||
run: echo ${{ github.sha }} > Release.txt
|
||||
- name: Test
|
||||
run: cat Release.txt
|
||||
- name: Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
if: github.ref_type == 'tag'
|
||||
with:
|
||||
files: |
|
||||
Release.txt
|
||||
@@ -139,7 +139,7 @@ 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.
|
||||
|
||||
### 📝 External release notes
|
||||
|
||||
@@ -157,17 +157,19 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
- name: Generate Changelog
|
||||
run: echo "# Good things have arrived" > ${{ github.workspace }}-CHANGELOG.txt
|
||||
- name: Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
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 }}
|
||||
```
|
||||
|
||||
@@ -183,13 +185,15 @@ The following are optional as `step.with` keys
|
||||
| `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 |
|
||||
| `prerelease` | Boolean | Indicator of whether or not is a prerelease |
|
||||
| `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. |
|
||||
| `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` |
|
||||
| `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 |
|
||||
| `append_body` | Boolean | Append to existing body instead of overwriting it |
|
||||
@@ -202,6 +206,14 @@ attempted first, then falling back on `body` if the path can not be read from.
|
||||
are not explicitly set and there is already an existing release for the tag, the
|
||||
release will retain its original info.
|
||||
|
||||
💡 `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
|
||||
|
||||
The following outputs can be accessed via `${{ steps.<step-id>.outputs }}` from this action
|
||||
@@ -211,7 +223,7 @@ The following outputs can be accessed via `${{ steps.<step-id>.outputs }}` from
|
||||
| `url` | String | Github.com URL for the release |
|
||||
| `id` | String | Release ID |
|
||||
| `upload_url` | String | URL for uploading assets to the release |
|
||||
| `assets` | String | JSON array containing information about each uploaded asset, in the format given [here](https://docs.github.com/en/rest/releases/assets#get-a-release-asset) (minus the `uploader` field) |
|
||||
| `assets` | String | JSON array containing information about each updated (newly uploaded or overwritten) asset, in the format given [here](https://docs.github.com/en/rest/releases/assets#get-a-release-asset) (minus the `uploader` field) |
|
||||
|
||||
As an example, you can use `${{ fromJSON(steps.<step-id>.outputs.assets)[0].browser_download_url }}` to get the download URL of the first asset.
|
||||
|
||||
|
||||
@@ -1,25 +1,844 @@
|
||||
//import * as assert from "assert";
|
||||
//const assert = require('assert');
|
||||
import * as assert from "assert";
|
||||
import { mimeOrDefault, asset } from "../src/github";
|
||||
import {
|
||||
asset,
|
||||
findTagFromReleases,
|
||||
finalizeRelease,
|
||||
mimeOrDefault,
|
||||
release,
|
||||
Release,
|
||||
Releaser,
|
||||
upload,
|
||||
} from '../src/github';
|
||||
|
||||
describe("github", () => {
|
||||
describe("mimeOrDefault", () => {
|
||||
it("returns a specific mime for common path", async () => {
|
||||
assert.equal(mimeOrDefault("foo.tar.gz"), "application/gzip");
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import { assert, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
describe('github', () => {
|
||||
const config = {
|
||||
github_token: 'test-token',
|
||||
github_ref: 'refs/tags/v1.0.0',
|
||||
github_repository: 'owner/repo',
|
||||
input_tag_name: undefined,
|
||||
input_name: undefined,
|
||||
input_body: undefined,
|
||||
input_body_path: undefined,
|
||||
input_files: [],
|
||||
input_draft: undefined,
|
||||
input_prerelease: undefined,
|
||||
input_preserve_order: undefined,
|
||||
input_overwrite_files: undefined,
|
||||
input_fail_on_unmatched_files: false,
|
||||
input_target_commitish: undefined,
|
||||
input_discussion_category_name: undefined,
|
||||
input_generate_release_notes: false,
|
||||
input_append_body: false,
|
||||
input_make_latest: undefined,
|
||||
};
|
||||
|
||||
describe('mimeOrDefault', () => {
|
||||
it('returns a specific mime for common path', async () => {
|
||||
assert.equal(mimeOrDefault('foo.tar.gz'), 'application/gzip');
|
||||
});
|
||||
it("returns default mime for uncommon path", async () => {
|
||||
assert.equal(mimeOrDefault("foo.uncommon"), "application/octet-stream");
|
||||
it('returns default mime for uncommon path', async () => {
|
||||
assert.equal(mimeOrDefault('foo.uncommon'), 'application/octet-stream');
|
||||
});
|
||||
});
|
||||
|
||||
describe("asset", () => {
|
||||
it("derives asset info from a path", async () => {
|
||||
const { name, mime, size, data } = asset("tests/data/foo/bar.txt");
|
||||
assert.equal(name, "bar.txt");
|
||||
assert.equal(mime, "text/plain");
|
||||
describe('asset', () => {
|
||||
it('derives asset info from a path', async () => {
|
||||
const { name, mime, size } = asset('tests/data/foo/bar.txt');
|
||||
assert.equal(name, 'bar.txt');
|
||||
assert.equal(mime, 'text/plain');
|
||||
assert.equal(size, 10);
|
||||
assert.equal(data.toString(), "release me");
|
||||
});
|
||||
});
|
||||
|
||||
describe('findTagFromReleases', () => {
|
||||
const owner = 'owner';
|
||||
const repo = 'repo';
|
||||
|
||||
const mockRelease: Release = {
|
||||
id: 1,
|
||||
upload_url: `https://api.github.com/repos/${owner}/${repo}/releases/1/assets`,
|
||||
html_url: `https://github.com/${owner}/${repo}/releases/tag/v1.0.0`,
|
||||
tag_name: 'v1.0.0',
|
||||
name: 'Test Release',
|
||||
body: 'Test body',
|
||||
target_commitish: 'main',
|
||||
draft: false,
|
||||
prerelease: false,
|
||||
assets: [],
|
||||
} as const;
|
||||
|
||||
const mockReleaser: Releaser = {
|
||||
getReleaseByTag: () => Promise.reject({ status: 404 }),
|
||||
createRelease: () => Promise.reject('Not implemented'),
|
||||
updateRelease: () => Promise.reject('Not implemented'),
|
||||
finalizeRelease: () => Promise.reject('Not implemented'),
|
||||
allReleases: async function* () {
|
||||
yield { data: [mockRelease] };
|
||||
},
|
||||
listReleaseAssets: () => Promise.reject('Not implemented'),
|
||||
deleteReleaseAsset: () => Promise.reject('Not implemented'),
|
||||
deleteRelease: () => Promise.reject('Not implemented'),
|
||||
updateReleaseAsset: () => Promise.reject('Not implemented'),
|
||||
uploadReleaseAsset: () => Promise.reject('Not implemented'),
|
||||
} as const;
|
||||
|
||||
it('finds a release by tag using direct API lookup', async () => {
|
||||
const targetTag = 'v1.0.0';
|
||||
const targetRelease = {
|
||||
...mockRelease,
|
||||
tag_name: targetTag,
|
||||
};
|
||||
|
||||
const releaser = {
|
||||
...mockReleaser,
|
||||
getReleaseByTag: () => Promise.resolve({ data: targetRelease }),
|
||||
};
|
||||
|
||||
const result = await findTagFromReleases(releaser, owner, repo, targetTag);
|
||||
|
||||
assert.deepStrictEqual(result, targetRelease);
|
||||
});
|
||||
|
||||
it('returns undefined when release is not found (404)', async () => {
|
||||
const releaser = {
|
||||
...mockReleaser,
|
||||
getReleaseByTag: () => Promise.reject({ status: 404 }),
|
||||
};
|
||||
|
||||
const result = await findTagFromReleases(releaser, owner, repo, 'nonexistent');
|
||||
|
||||
assert.strictEqual(result, undefined);
|
||||
});
|
||||
|
||||
it('re-throws non-404 errors', async () => {
|
||||
const releaser = {
|
||||
...mockReleaser,
|
||||
getReleaseByTag: () => Promise.reject({ status: 500, message: 'Server error' }),
|
||||
};
|
||||
|
||||
try {
|
||||
await findTagFromReleases(releaser, owner, repo, 'v1.0.0');
|
||||
assert.fail('Expected an error to be thrown');
|
||||
} catch (error) {
|
||||
assert.strictEqual(error.status, 500);
|
||||
}
|
||||
});
|
||||
|
||||
it('finds a release with empty tag name', async () => {
|
||||
const emptyTag = '';
|
||||
const targetRelease = {
|
||||
...mockRelease,
|
||||
tag_name: emptyTag,
|
||||
};
|
||||
|
||||
const releaser = {
|
||||
...mockReleaser,
|
||||
getReleaseByTag: () => Promise.resolve({ data: targetRelease }),
|
||||
};
|
||||
|
||||
const result = await findTagFromReleases(releaser, owner, repo, emptyTag);
|
||||
|
||||
assert.deepStrictEqual(result, targetRelease);
|
||||
});
|
||||
});
|
||||
|
||||
describe('finalizeRelease input_draft behavior', () => {
|
||||
const draftRelease: Release = {
|
||||
id: 1,
|
||||
upload_url: 'test',
|
||||
html_url: 'test',
|
||||
tag_name: 'v1.0.0',
|
||||
name: 'test',
|
||||
body: 'test',
|
||||
target_commitish: 'main',
|
||||
draft: true,
|
||||
prerelease: false,
|
||||
assets: [],
|
||||
};
|
||||
|
||||
const finalizedRelease: Release = {
|
||||
...draftRelease,
|
||||
draft: false,
|
||||
};
|
||||
const publishedPrerelease: Release = {
|
||||
...draftRelease,
|
||||
draft: false,
|
||||
prerelease: true,
|
||||
};
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: 'returns early when input_draft is true',
|
||||
input_draft: true,
|
||||
release: draftRelease,
|
||||
expectedCalls: 0,
|
||||
expectedResult: draftRelease,
|
||||
},
|
||||
{
|
||||
name: 'finalizes release when input_draft is false',
|
||||
input_draft: false,
|
||||
release: draftRelease,
|
||||
expectedCalls: 1,
|
||||
expectedResult: finalizedRelease,
|
||||
},
|
||||
{
|
||||
name: 'returns early when release is already published',
|
||||
input_draft: false,
|
||||
release: publishedPrerelease,
|
||||
expectedCalls: 0,
|
||||
expectedResult: publishedPrerelease,
|
||||
},
|
||||
])('$name', async ({ input_draft, release, expectedCalls, expectedResult }) => {
|
||||
const finalizeReleaseSpy = vi.fn(async () => ({ data: finalizedRelease }));
|
||||
|
||||
const releaser: Releaser = {
|
||||
getReleaseByTag: () => Promise.reject('Not implemented'),
|
||||
createRelease: () => Promise.reject('Not implemented'),
|
||||
updateRelease: () => Promise.reject('Not implemented'),
|
||||
finalizeRelease: finalizeReleaseSpy,
|
||||
allReleases: async function* () {
|
||||
throw new Error('Not implemented');
|
||||
},
|
||||
listReleaseAssets: () => Promise.reject('Not implemented'),
|
||||
deleteReleaseAsset: () => Promise.reject('Not implemented'),
|
||||
deleteRelease: () => Promise.reject('Not implemented'),
|
||||
updateReleaseAsset: () => Promise.reject('Not implemented'),
|
||||
uploadReleaseAsset: () => Promise.reject('Not implemented'),
|
||||
};
|
||||
|
||||
const result = await finalizeRelease(
|
||||
{
|
||||
...config,
|
||||
input_draft,
|
||||
},
|
||||
releaser,
|
||||
release,
|
||||
);
|
||||
|
||||
expect(finalizeReleaseSpy).toHaveBeenCalledTimes(expectedCalls);
|
||||
assert.strictEqual(result, expectedResult);
|
||||
|
||||
if (expectedCalls === 1) {
|
||||
expect(finalizeReleaseSpy).toHaveBeenCalledWith({
|
||||
owner: 'owner',
|
||||
repo: 'repo',
|
||||
release_id: release.id,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('deletes a newly created draft when tag creation is blocked by repository rules', async () => {
|
||||
const finalizeReleaseSpy = vi.fn(async () => {
|
||||
throw {
|
||||
status: 422,
|
||||
response: {
|
||||
data: {
|
||||
errors: [
|
||||
{
|
||||
field: 'pre_receive',
|
||||
message:
|
||||
'pre_receive Repository rule violations found\n\nCannot create ref due to creations being restricted.\n\n',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
const deleteReleaseSpy = vi.fn(async () => undefined);
|
||||
|
||||
const releaser: Releaser = {
|
||||
getReleaseByTag: () => Promise.reject('Not implemented'),
|
||||
createRelease: () => Promise.reject('Not implemented'),
|
||||
updateRelease: () => Promise.reject('Not implemented'),
|
||||
finalizeRelease: finalizeReleaseSpy,
|
||||
allReleases: async function* () {
|
||||
throw new Error('Not implemented');
|
||||
},
|
||||
listReleaseAssets: () => Promise.reject('Not implemented'),
|
||||
deleteReleaseAsset: () => Promise.reject('Not implemented'),
|
||||
deleteRelease: deleteReleaseSpy,
|
||||
updateReleaseAsset: () => Promise.reject('Not implemented'),
|
||||
uploadReleaseAsset: () => Promise.reject('Not implemented'),
|
||||
};
|
||||
|
||||
await expect(
|
||||
finalizeRelease(
|
||||
{
|
||||
...config,
|
||||
input_draft: false,
|
||||
},
|
||||
releaser,
|
||||
draftRelease,
|
||||
true,
|
||||
),
|
||||
).rejects.toThrow(
|
||||
'Tag creation for v1.0.0 is blocked by repository rules. Deleted draft release 1 to avoid leaving an orphaned draft release.',
|
||||
);
|
||||
|
||||
expect(finalizeReleaseSpy).toHaveBeenCalledTimes(1);
|
||||
expect(deleteReleaseSpy).toHaveBeenCalledWith({
|
||||
owner: 'owner',
|
||||
repo: 'repo',
|
||||
release_id: draftRelease.id,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not delete an existing draft release when tag creation is blocked by repository rules', async () => {
|
||||
const finalizeReleaseSpy = vi.fn(async () => {
|
||||
throw {
|
||||
status: 422,
|
||||
response: {
|
||||
data: {
|
||||
errors: [
|
||||
{
|
||||
field: 'pre_receive',
|
||||
message:
|
||||
'pre_receive Repository rule violations found\n\nCannot create ref due to creations being restricted.\n\n',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
const deleteReleaseSpy = vi.fn(async () => undefined);
|
||||
|
||||
const releaser: Releaser = {
|
||||
getReleaseByTag: () => Promise.reject('Not implemented'),
|
||||
createRelease: () => Promise.reject('Not implemented'),
|
||||
updateRelease: () => Promise.reject('Not implemented'),
|
||||
finalizeRelease: finalizeReleaseSpy,
|
||||
allReleases: async function* () {
|
||||
throw new Error('Not implemented');
|
||||
},
|
||||
listReleaseAssets: () => Promise.reject('Not implemented'),
|
||||
deleteReleaseAsset: () => Promise.reject('Not implemented'),
|
||||
deleteRelease: deleteReleaseSpy,
|
||||
updateReleaseAsset: () => Promise.reject('Not implemented'),
|
||||
uploadReleaseAsset: () => Promise.reject('Not implemented'),
|
||||
};
|
||||
|
||||
await expect(
|
||||
finalizeRelease(
|
||||
{
|
||||
...config,
|
||||
input_draft: false,
|
||||
},
|
||||
releaser,
|
||||
draftRelease,
|
||||
false,
|
||||
1,
|
||||
),
|
||||
).rejects.toThrow('Too many retries.');
|
||||
|
||||
expect(finalizeReleaseSpy).toHaveBeenCalledTimes(1);
|
||||
expect(deleteReleaseSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('creates published prereleases without the forced draft-first path', async () => {
|
||||
const prereleaseConfig = {
|
||||
...config,
|
||||
input_prerelease: true,
|
||||
input_draft: false,
|
||||
};
|
||||
const createdRelease: Release = {
|
||||
id: 1,
|
||||
upload_url: 'test',
|
||||
html_url: 'test',
|
||||
tag_name: 'v1.0.0',
|
||||
name: 'test',
|
||||
body: 'test',
|
||||
target_commitish: 'main',
|
||||
draft: false,
|
||||
prerelease: true,
|
||||
assets: [],
|
||||
};
|
||||
|
||||
const createReleaseSpy = vi.fn(async () => ({ data: createdRelease }));
|
||||
const mockReleaser: Releaser = {
|
||||
getReleaseByTag: () => Promise.reject({ status: 404 }),
|
||||
createRelease: createReleaseSpy,
|
||||
updateRelease: () => Promise.reject('Not implemented'),
|
||||
finalizeRelease: () => Promise.reject('Not implemented'),
|
||||
allReleases: async function* () {
|
||||
yield { data: [createdRelease] };
|
||||
},
|
||||
listReleaseAssets: () => Promise.reject('Not implemented'),
|
||||
deleteReleaseAsset: () => Promise.reject('Not implemented'),
|
||||
deleteRelease: () => Promise.reject('Not implemented'),
|
||||
updateReleaseAsset: () => Promise.reject('Not implemented'),
|
||||
uploadReleaseAsset: () => Promise.reject('Not implemented'),
|
||||
} as const;
|
||||
|
||||
const result = await release(prereleaseConfig, mockReleaser, 1);
|
||||
|
||||
assert.equal(result.release.id, createdRelease.id);
|
||||
assert.equal(result.created, true);
|
||||
expect(createReleaseSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
draft: false,
|
||||
prerelease: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('retries upload after deleting conflicting asset on 422 already_exists race', async () => {
|
||||
const uploadReleaseAsset = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce({
|
||||
status: 422,
|
||||
response: { data: { errors: [{ code: 'already_exists' }] } },
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
status: 201,
|
||||
data: { id: 123, name: 'release.txt' },
|
||||
});
|
||||
|
||||
const listReleaseAssets = vi.fn().mockResolvedValue([{ id: 99, name: 'release.txt' }]);
|
||||
const deleteReleaseAsset = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
const mockReleaser: Releaser = {
|
||||
getReleaseByTag: () => Promise.reject('Not implemented'),
|
||||
createRelease: () => Promise.reject('Not implemented'),
|
||||
updateRelease: () => Promise.reject('Not implemented'),
|
||||
finalizeRelease: () => Promise.reject('Not implemented'),
|
||||
allReleases: async function* () {
|
||||
throw new Error('Not implemented');
|
||||
},
|
||||
listReleaseAssets,
|
||||
deleteReleaseAsset,
|
||||
uploadReleaseAsset,
|
||||
};
|
||||
|
||||
const result = await upload(
|
||||
config,
|
||||
mockReleaser,
|
||||
'https://uploads.github.com/repos/owner/repo/releases/1/assets',
|
||||
'__tests__/release.txt',
|
||||
[],
|
||||
);
|
||||
|
||||
expect(result).toStrictEqual({ id: 123, name: 'release.txt' });
|
||||
expect(listReleaseAssets).toHaveBeenCalledWith({
|
||||
owner: 'owner',
|
||||
repo: 'repo',
|
||||
release_id: 1,
|
||||
});
|
||||
expect(deleteReleaseAsset).toHaveBeenCalledWith({
|
||||
owner: 'owner',
|
||||
repo: 'repo',
|
||||
asset_id: 99,
|
||||
});
|
||||
expect(uploadReleaseAsset).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('handles 422 already_exists error gracefully', async () => {
|
||||
const existingRelease = {
|
||||
id: 1,
|
||||
upload_url: 'test',
|
||||
html_url: 'test',
|
||||
tag_name: 'v1.0.0',
|
||||
name: 'test',
|
||||
body: 'test',
|
||||
target_commitish: 'main',
|
||||
draft: false,
|
||||
prerelease: false,
|
||||
assets: [],
|
||||
};
|
||||
|
||||
let createAttempts = 0;
|
||||
const mockReleaser: Releaser = {
|
||||
getReleaseByTag: ({ tag }) => {
|
||||
// First call returns 404 (release doesn't exist yet), subsequent calls find it
|
||||
if (createAttempts === 0) {
|
||||
return Promise.reject({ status: 404 });
|
||||
}
|
||||
return Promise.resolve({ data: existingRelease });
|
||||
},
|
||||
createRelease: () => {
|
||||
createAttempts++;
|
||||
return Promise.reject({
|
||||
status: 422,
|
||||
response: { data: { errors: [{ code: 'already_exists' }] } },
|
||||
});
|
||||
},
|
||||
updateRelease: () =>
|
||||
Promise.resolve({
|
||||
data: {
|
||||
id: 1,
|
||||
upload_url: 'test',
|
||||
html_url: 'test',
|
||||
tag_name: 'v1.0.0',
|
||||
name: 'test',
|
||||
body: 'test',
|
||||
target_commitish: 'main',
|
||||
draft: true,
|
||||
prerelease: false,
|
||||
assets: [],
|
||||
},
|
||||
}),
|
||||
finalizeRelease: () => Promise.reject('Not implemented'),
|
||||
allReleases: async function* () {
|
||||
yield { data: [existingRelease] };
|
||||
},
|
||||
listReleaseAssets: () => Promise.reject('Not implemented'),
|
||||
deleteReleaseAsset: () => Promise.reject('Not implemented'),
|
||||
deleteRelease: () => Promise.reject('Not implemented'),
|
||||
uploadReleaseAsset: () => Promise.reject('Not implemented'),
|
||||
} as const;
|
||||
|
||||
const result = await release(config, mockReleaser, 2);
|
||||
assert.ok(result);
|
||||
assert.equal(result.release.id, 1);
|
||||
assert.equal(result.created, false);
|
||||
});
|
||||
|
||||
it('normalizes refs/tags-prefixed input_tag_name values before reusing an existing release', async () => {
|
||||
const existingRelease: Release = {
|
||||
id: 1,
|
||||
upload_url: 'test',
|
||||
html_url: 'test',
|
||||
tag_name: 'v1.0.0',
|
||||
name: 'test',
|
||||
body: 'test',
|
||||
target_commitish: 'main',
|
||||
draft: false,
|
||||
prerelease: false,
|
||||
assets: [],
|
||||
};
|
||||
|
||||
const updateReleaseSpy = vi.fn(async () => ({ data: existingRelease }));
|
||||
const getReleaseByTagSpy = vi.fn(async () => ({ data: existingRelease }));
|
||||
const result = await release(
|
||||
{
|
||||
...config,
|
||||
input_tag_name: 'refs/tags/v1.0.0',
|
||||
},
|
||||
{
|
||||
getReleaseByTag: getReleaseByTagSpy,
|
||||
createRelease: () => Promise.reject('Not implemented'),
|
||||
updateRelease: updateReleaseSpy,
|
||||
finalizeRelease: () => Promise.reject('Not implemented'),
|
||||
allReleases: async function* () {
|
||||
yield { data: [existingRelease] };
|
||||
},
|
||||
listReleaseAssets: () => Promise.reject('Not implemented'),
|
||||
deleteReleaseAsset: () => Promise.reject('Not implemented'),
|
||||
deleteRelease: () => Promise.reject('Not implemented'),
|
||||
updateReleaseAsset: () => Promise.reject('Not implemented'),
|
||||
uploadReleaseAsset: () => Promise.reject('Not implemented'),
|
||||
},
|
||||
1,
|
||||
);
|
||||
|
||||
expect(getReleaseByTagSpy).toHaveBeenCalledWith({
|
||||
owner: 'owner',
|
||||
repo: 'repo',
|
||||
tag: 'v1.0.0',
|
||||
});
|
||||
expect(updateReleaseSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
tag_name: 'v1.0.0',
|
||||
}),
|
||||
);
|
||||
assert.equal(result.release.id, existingRelease.id);
|
||||
assert.equal(result.created, false);
|
||||
});
|
||||
|
||||
it('reuses a canonical release after concurrent create success and removes empty duplicates', async () => {
|
||||
const canonicalRelease: Release = {
|
||||
id: 1,
|
||||
upload_url: 'canonical-upload',
|
||||
html_url: 'canonical-html',
|
||||
tag_name: 'v1.0.0',
|
||||
name: 'canonical',
|
||||
body: 'test',
|
||||
target_commitish: 'main',
|
||||
draft: true,
|
||||
prerelease: false,
|
||||
assets: [],
|
||||
};
|
||||
const duplicateRelease: Release = {
|
||||
id: 2,
|
||||
upload_url: 'duplicate-upload',
|
||||
html_url: 'duplicate-html',
|
||||
tag_name: 'v1.0.0',
|
||||
name: 'duplicate',
|
||||
body: 'test',
|
||||
target_commitish: 'main',
|
||||
draft: true,
|
||||
prerelease: false,
|
||||
assets: [],
|
||||
};
|
||||
|
||||
let lookupCount = 0;
|
||||
const deleteReleaseSpy = vi.fn(async () => undefined);
|
||||
const mockReleaser: Releaser = {
|
||||
getReleaseByTag: () => {
|
||||
lookupCount += 1;
|
||||
if (lookupCount === 1) {
|
||||
return Promise.reject({ status: 404 });
|
||||
}
|
||||
return Promise.resolve({ data: canonicalRelease });
|
||||
},
|
||||
createRelease: () => Promise.resolve({ data: duplicateRelease }),
|
||||
updateRelease: () => Promise.reject('Not implemented'),
|
||||
finalizeRelease: () => Promise.reject('Not implemented'),
|
||||
allReleases: async function* () {
|
||||
yield { data: [duplicateRelease, canonicalRelease] };
|
||||
},
|
||||
listReleaseAssets: () => Promise.reject('Not implemented'),
|
||||
deleteReleaseAsset: () => Promise.reject('Not implemented'),
|
||||
deleteRelease: deleteReleaseSpy,
|
||||
updateReleaseAsset: () => Promise.reject('Not implemented'),
|
||||
uploadReleaseAsset: () => Promise.reject('Not implemented'),
|
||||
};
|
||||
|
||||
const result = await release(config, mockReleaser, 2);
|
||||
|
||||
assert.equal(result.release.id, canonicalRelease.id);
|
||||
assert.equal(result.created, false);
|
||||
expect(deleteReleaseSpy).toHaveBeenCalledWith({
|
||||
owner: 'owner',
|
||||
repo: 'repo',
|
||||
release_id: duplicateRelease.id,
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to recent releases when tag lookup still lags after create', async () => {
|
||||
const canonicalRelease: Release = {
|
||||
id: 1,
|
||||
upload_url: 'canonical-upload',
|
||||
html_url: 'canonical-html',
|
||||
tag_name: 'v1.0.0',
|
||||
name: 'canonical',
|
||||
body: 'test',
|
||||
target_commitish: 'main',
|
||||
draft: true,
|
||||
prerelease: false,
|
||||
assets: [],
|
||||
};
|
||||
const duplicateRelease: Release = {
|
||||
id: 2,
|
||||
upload_url: 'duplicate-upload',
|
||||
html_url: 'duplicate-html',
|
||||
tag_name: 'v1.0.0',
|
||||
name: 'duplicate',
|
||||
body: 'test',
|
||||
target_commitish: 'main',
|
||||
draft: true,
|
||||
prerelease: false,
|
||||
assets: [],
|
||||
};
|
||||
|
||||
const deleteReleaseSpy = vi.fn(async () => undefined);
|
||||
const mockReleaser: Releaser = {
|
||||
getReleaseByTag: () => Promise.reject({ status: 404 }),
|
||||
createRelease: () => Promise.resolve({ data: duplicateRelease }),
|
||||
updateRelease: () => Promise.reject('Not implemented'),
|
||||
finalizeRelease: () => Promise.reject('Not implemented'),
|
||||
allReleases: async function* () {
|
||||
yield { data: [duplicateRelease, canonicalRelease] };
|
||||
},
|
||||
listReleaseAssets: () => Promise.reject('Not implemented'),
|
||||
deleteReleaseAsset: () => Promise.reject('Not implemented'),
|
||||
deleteRelease: deleteReleaseSpy,
|
||||
updateReleaseAsset: () => Promise.reject('Not implemented'),
|
||||
uploadReleaseAsset: () => Promise.reject('Not implemented'),
|
||||
};
|
||||
|
||||
const result = await release(config, mockReleaser, 1);
|
||||
|
||||
assert.equal(result.release.id, canonicalRelease.id);
|
||||
assert.equal(result.created, false);
|
||||
expect(deleteReleaseSpy).toHaveBeenCalledWith({
|
||||
owner: 'owner',
|
||||
repo: 'repo',
|
||||
release_id: duplicateRelease.id,
|
||||
});
|
||||
});
|
||||
|
||||
it('deletes the just-created duplicate draft even if recent release listing misses it', async () => {
|
||||
const canonicalRelease: Release = {
|
||||
id: 1,
|
||||
upload_url: 'canonical-upload',
|
||||
html_url: 'canonical-html',
|
||||
tag_name: 'v1.0.0',
|
||||
name: 'canonical',
|
||||
body: 'test',
|
||||
target_commitish: 'main',
|
||||
draft: true,
|
||||
prerelease: false,
|
||||
assets: [],
|
||||
};
|
||||
const duplicateRelease: Release = {
|
||||
id: 2,
|
||||
upload_url: 'duplicate-upload',
|
||||
html_url: 'duplicate-html',
|
||||
tag_name: 'v1.0.0',
|
||||
name: 'duplicate',
|
||||
body: 'test',
|
||||
target_commitish: 'main',
|
||||
draft: true,
|
||||
prerelease: false,
|
||||
assets: [],
|
||||
};
|
||||
|
||||
let lookupCount = 0;
|
||||
const deleteReleaseSpy = vi.fn(async () => undefined);
|
||||
const mockReleaser: Releaser = {
|
||||
getReleaseByTag: () => {
|
||||
lookupCount += 1;
|
||||
if (lookupCount === 1) {
|
||||
return Promise.reject({ status: 404 });
|
||||
}
|
||||
return Promise.resolve({ data: canonicalRelease });
|
||||
},
|
||||
createRelease: () => Promise.resolve({ data: duplicateRelease }),
|
||||
updateRelease: () => Promise.reject('Not implemented'),
|
||||
finalizeRelease: () => Promise.reject('Not implemented'),
|
||||
allReleases: async function* () {
|
||||
yield { data: [canonicalRelease] };
|
||||
},
|
||||
listReleaseAssets: () => Promise.reject('Not implemented'),
|
||||
deleteReleaseAsset: () => Promise.reject('Not implemented'),
|
||||
deleteRelease: deleteReleaseSpy,
|
||||
updateReleaseAsset: () => Promise.reject('Not implemented'),
|
||||
uploadReleaseAsset: () => Promise.reject('Not implemented'),
|
||||
};
|
||||
|
||||
const result = await release(config, mockReleaser, 2);
|
||||
|
||||
assert.equal(result.release.id, canonicalRelease.id);
|
||||
assert.equal(result.created, false);
|
||||
expect(deleteReleaseSpy).toHaveBeenCalledWith({
|
||||
owner: 'owner',
|
||||
repo: 'repo',
|
||||
release_id: duplicateRelease.id,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('upload', () => {
|
||||
it('restores a dotfile label when GitHub normalizes the uploaded asset name', async () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), 'gh-release-dotfile-'));
|
||||
const dotfilePath = join(tempDir, '.config');
|
||||
writeFileSync(dotfilePath, 'config');
|
||||
|
||||
const updateReleaseAssetSpy = vi.fn(async () => ({
|
||||
data: {
|
||||
id: 1,
|
||||
name: 'default.config',
|
||||
label: '.config',
|
||||
},
|
||||
}));
|
||||
const releaser: Releaser = {
|
||||
getReleaseByTag: () => Promise.reject('Not implemented'),
|
||||
createRelease: () => Promise.reject('Not implemented'),
|
||||
updateRelease: () => Promise.reject('Not implemented'),
|
||||
finalizeRelease: () => Promise.reject('Not implemented'),
|
||||
allReleases: async function* () {
|
||||
throw new Error('Not implemented');
|
||||
},
|
||||
listReleaseAssets: () => Promise.reject('Not implemented'),
|
||||
deleteReleaseAsset: () => Promise.reject('Not implemented'),
|
||||
deleteRelease: () => Promise.reject('Not implemented'),
|
||||
updateReleaseAsset: updateReleaseAssetSpy,
|
||||
uploadReleaseAsset: () =>
|
||||
Promise.resolve({
|
||||
status: 201,
|
||||
data: {
|
||||
id: 1,
|
||||
name: 'default.config',
|
||||
label: '',
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await upload(
|
||||
config,
|
||||
releaser,
|
||||
'https://uploads.example.test/assets',
|
||||
dotfilePath,
|
||||
[],
|
||||
);
|
||||
|
||||
expect(updateReleaseAssetSpy).toHaveBeenCalledWith({
|
||||
owner: 'owner',
|
||||
repo: 'repo',
|
||||
asset_id: 1,
|
||||
name: 'default.config',
|
||||
label: '.config',
|
||||
});
|
||||
expect(result).toEqual({
|
||||
id: 1,
|
||||
name: 'default.config',
|
||||
label: '.config',
|
||||
});
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('matches an existing asset by label when overwriting a dotfile', async () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), 'gh-release-dotfile-'));
|
||||
const dotfilePath = join(tempDir, '.config');
|
||||
writeFileSync(dotfilePath, 'config');
|
||||
|
||||
const deleteReleaseAssetSpy = vi.fn(async () => undefined);
|
||||
const releaser: Releaser = {
|
||||
getReleaseByTag: () => Promise.reject('Not implemented'),
|
||||
createRelease: () => Promise.reject('Not implemented'),
|
||||
updateRelease: () => Promise.reject('Not implemented'),
|
||||
finalizeRelease: () => Promise.reject('Not implemented'),
|
||||
allReleases: async function* () {
|
||||
throw new Error('Not implemented');
|
||||
},
|
||||
listReleaseAssets: () => Promise.reject('Not implemented'),
|
||||
deleteReleaseAsset: deleteReleaseAssetSpy,
|
||||
deleteRelease: () => Promise.reject('Not implemented'),
|
||||
updateReleaseAsset: () =>
|
||||
Promise.resolve({
|
||||
data: {
|
||||
id: 2,
|
||||
name: 'default.config',
|
||||
label: '.config',
|
||||
},
|
||||
}),
|
||||
uploadReleaseAsset: () =>
|
||||
Promise.resolve({
|
||||
status: 201,
|
||||
data: {
|
||||
id: 2,
|
||||
name: 'default.config',
|
||||
label: '',
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
try {
|
||||
await upload(config, releaser, 'https://uploads.example.test/assets', dotfilePath, [
|
||||
{
|
||||
id: 1,
|
||||
name: 'default.config',
|
||||
label: '.config',
|
||||
},
|
||||
]);
|
||||
|
||||
expect(deleteReleaseAssetSpy).toHaveBeenCalledWith({
|
||||
asset_id: 1,
|
||||
owner: 'owner',
|
||||
repo: 'repo',
|
||||
});
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,52 +1,76 @@
|
||||
import {
|
||||
releaseBody,
|
||||
alignAssetName,
|
||||
expandHomePattern,
|
||||
isTag,
|
||||
paths,
|
||||
normalizeFilePattern,
|
||||
normalizeGlobPattern,
|
||||
normalizeTagName,
|
||||
parseConfig,
|
||||
parseInputFiles,
|
||||
paths,
|
||||
releaseBody,
|
||||
unmatchedPatterns,
|
||||
uploadUrl,
|
||||
} from "../src/util";
|
||||
import * as assert from "assert";
|
||||
} from '../src/util';
|
||||
|
||||
describe("util", () => {
|
||||
describe("uploadUrl", () => {
|
||||
it("strips template", () => {
|
||||
import { assert, describe, expect, it } from 'vitest';
|
||||
|
||||
describe('util', () => {
|
||||
describe('uploadUrl', () => {
|
||||
it('strips template', () => {
|
||||
assert.equal(
|
||||
uploadUrl(
|
||||
"https://uploads.github.com/repos/octocat/Hello-World/releases/1/assets{?name,label}",
|
||||
'https://uploads.github.com/repos/octocat/Hello-World/releases/1/assets{?name,label}',
|
||||
),
|
||||
"https://uploads.github.com/repos/octocat/Hello-World/releases/1/assets",
|
||||
'https://uploads.github.com/repos/octocat/Hello-World/releases/1/assets',
|
||||
);
|
||||
});
|
||||
});
|
||||
describe("parseInputFiles", () => {
|
||||
it("parses empty strings", () => {
|
||||
assert.deepStrictEqual(parseInputFiles(""), []);
|
||||
describe('parseInputFiles', () => {
|
||||
it('parses empty strings', () => {
|
||||
assert.deepStrictEqual(parseInputFiles(''), []);
|
||||
});
|
||||
it("parses comma-delimited strings", () => {
|
||||
assert.deepStrictEqual(parseInputFiles("foo,bar"), ["foo", "bar"]);
|
||||
it('parses comma-delimited strings', () => {
|
||||
assert.deepStrictEqual(parseInputFiles('foo,bar'), ['foo', 'bar']);
|
||||
});
|
||||
it("parses newline and comma-delimited (and then some)", () => {
|
||||
assert.deepStrictEqual(
|
||||
parseInputFiles("foo,bar\nbaz,boom,\n\ndoom,loom "),
|
||||
["foo", "bar", "baz", "boom", "doom", "loom"],
|
||||
);
|
||||
it('parses newline and comma-delimited (and then some)', () => {
|
||||
assert.deepStrictEqual(parseInputFiles('foo,bar\nbaz,boom,\n\ndoom,loom '), [
|
||||
'foo',
|
||||
'bar',
|
||||
'baz',
|
||||
'boom',
|
||||
'doom',
|
||||
'loom',
|
||||
]);
|
||||
});
|
||||
it('handles globs with brace groups containing commas', () => {
|
||||
assert.deepStrictEqual(parseInputFiles('./**/*.{exe,deb,tar.gz}\nfoo,bar'), [
|
||||
'./**/*.{exe,deb,tar.gz}',
|
||||
'foo',
|
||||
'bar',
|
||||
]);
|
||||
});
|
||||
it('handles single-line brace pattern correctly', () => {
|
||||
assert.deepStrictEqual(parseInputFiles('./**/*.{exe,deb,tar.gz}'), [
|
||||
'./**/*.{exe,deb,tar.gz}',
|
||||
]);
|
||||
});
|
||||
});
|
||||
describe("releaseBody", () => {
|
||||
it("uses input body", () => {
|
||||
describe('releaseBody', () => {
|
||||
it('uses input body', () => {
|
||||
assert.equal(
|
||||
"foo",
|
||||
'foo',
|
||||
releaseBody({
|
||||
github_ref: "",
|
||||
github_repository: "",
|
||||
github_token: "",
|
||||
input_body: "foo",
|
||||
github_ref: '',
|
||||
github_repository: '',
|
||||
github_token: '',
|
||||
input_body: 'foo',
|
||||
input_body_path: undefined,
|
||||
input_draft: false,
|
||||
input_prerelease: false,
|
||||
input_preserve_order: undefined,
|
||||
input_files: [],
|
||||
input_overwrite_files: undefined,
|
||||
input_name: undefined,
|
||||
input_tag_name: undefined,
|
||||
input_target_commitish: undefined,
|
||||
@@ -56,18 +80,20 @@ describe("util", () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
it("uses input body path", () => {
|
||||
it('uses input body path', () => {
|
||||
assert.equal(
|
||||
"bar",
|
||||
'bar',
|
||||
releaseBody({
|
||||
github_ref: "",
|
||||
github_repository: "",
|
||||
github_token: "",
|
||||
github_ref: '',
|
||||
github_repository: '',
|
||||
github_token: '',
|
||||
input_body: undefined,
|
||||
input_body_path: "__tests__/release.txt",
|
||||
input_body_path: '__tests__/release.txt',
|
||||
input_draft: false,
|
||||
input_prerelease: false,
|
||||
input_preserve_order: undefined,
|
||||
input_files: [],
|
||||
input_overwrite_files: undefined,
|
||||
input_name: undefined,
|
||||
input_tag_name: undefined,
|
||||
input_target_commitish: undefined,
|
||||
@@ -77,18 +103,20 @@ describe("util", () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
it("defaults to body path when both body and body path are provided", () => {
|
||||
it('defaults to body path when both body and body path are provided', () => {
|
||||
assert.equal(
|
||||
"bar",
|
||||
'bar',
|
||||
releaseBody({
|
||||
github_ref: "",
|
||||
github_repository: "",
|
||||
github_token: "",
|
||||
input_body: "foo",
|
||||
input_body_path: "__tests__/release.txt",
|
||||
github_ref: '',
|
||||
github_repository: '',
|
||||
github_token: '',
|
||||
input_body: 'foo',
|
||||
input_body_path: '__tests__/release.txt',
|
||||
input_draft: false,
|
||||
input_prerelease: false,
|
||||
input_preserve_order: undefined,
|
||||
input_files: [],
|
||||
input_overwrite_files: undefined,
|
||||
input_name: undefined,
|
||||
input_tag_name: undefined,
|
||||
input_target_commitish: undefined,
|
||||
@@ -98,9 +126,55 @@ describe("util", () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
it('falls back to body when body_path is missing', () => {
|
||||
assert.equal(
|
||||
releaseBody({
|
||||
github_ref: '',
|
||||
github_repository: '',
|
||||
github_token: '',
|
||||
input_body: 'fallback-body',
|
||||
input_body_path: '__tests__/does-not-exist.txt',
|
||||
input_draft: false,
|
||||
input_prerelease: false,
|
||||
input_files: [],
|
||||
input_overwrite_files: undefined,
|
||||
input_preserve_order: undefined,
|
||||
input_name: undefined,
|
||||
input_tag_name: undefined,
|
||||
input_target_commitish: undefined,
|
||||
input_discussion_category_name: undefined,
|
||||
input_generate_release_notes: false,
|
||||
input_make_latest: undefined,
|
||||
}),
|
||||
'fallback-body',
|
||||
);
|
||||
});
|
||||
it('returns undefined when body_path is missing and body is not provided', () => {
|
||||
assert.equal(
|
||||
releaseBody({
|
||||
github_ref: '',
|
||||
github_repository: '',
|
||||
github_token: '',
|
||||
input_body: undefined,
|
||||
input_body_path: '__tests__/does-not-exist.txt',
|
||||
input_draft: false,
|
||||
input_prerelease: false,
|
||||
input_files: [],
|
||||
input_overwrite_files: undefined,
|
||||
input_preserve_order: undefined,
|
||||
input_name: undefined,
|
||||
input_tag_name: undefined,
|
||||
input_target_commitish: undefined,
|
||||
input_discussion_category_name: undefined,
|
||||
input_generate_release_notes: false,
|
||||
input_make_latest: undefined,
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
describe("parseConfig", () => {
|
||||
it("parses basic config", () => {
|
||||
describe('parseConfig', () => {
|
||||
it('parses basic config', () => {
|
||||
assert.deepStrictEqual(
|
||||
parseConfig({
|
||||
// note: inputs declared in actions.yml, even when declared not required,
|
||||
@@ -109,19 +183,22 @@ describe("util", () => {
|
||||
// as an empty string !== undefined in terms of what we pass to the api
|
||||
// so we cover that in a test case here to ensure undefined values are actually
|
||||
// resolved as undefined and not empty strings
|
||||
INPUT_TARGET_COMMITISH: "",
|
||||
INPUT_DISCUSSION_CATEGORY_NAME: "",
|
||||
INPUT_TARGET_COMMITISH: '',
|
||||
INPUT_DISCUSSION_CATEGORY_NAME: '',
|
||||
}),
|
||||
{
|
||||
github_ref: "",
|
||||
github_repository: "",
|
||||
github_token: "",
|
||||
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,
|
||||
@@ -133,72 +210,81 @@ describe("util", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("parses basic config with commitish", () => {
|
||||
it('parses basic config with commitish', () => {
|
||||
assert.deepStrictEqual(
|
||||
parseConfig({
|
||||
INPUT_TARGET_COMMITISH: "affa18ef97bc9db20076945705aba8c516139abd",
|
||||
INPUT_TARGET_COMMITISH: 'affa18ef97bc9db20076945705aba8c516139abd',
|
||||
}),
|
||||
{
|
||||
github_ref: "",
|
||||
github_repository: "",
|
||||
github_token: "",
|
||||
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,
|
||||
input_target_commitish: "affa18ef97bc9db20076945705aba8c516139abd",
|
||||
input_target_commitish: 'affa18ef97bc9db20076945705aba8c516139abd',
|
||||
input_discussion_category_name: undefined,
|
||||
input_generate_release_notes: false,
|
||||
input_make_latest: undefined,
|
||||
},
|
||||
);
|
||||
});
|
||||
it("supports discussion category names", () => {
|
||||
it('supports discussion category names', () => {
|
||||
assert.deepStrictEqual(
|
||||
parseConfig({
|
||||
INPUT_DISCUSSION_CATEGORY_NAME: "releases",
|
||||
INPUT_DISCUSSION_CATEGORY_NAME: 'releases',
|
||||
}),
|
||||
{
|
||||
github_ref: "",
|
||||
github_repository: "",
|
||||
github_token: "",
|
||||
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,
|
||||
input_discussion_category_name: "releases",
|
||||
input_discussion_category_name: 'releases',
|
||||
input_generate_release_notes: false,
|
||||
input_make_latest: undefined,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("supports generating release notes", () => {
|
||||
it('supports generating release notes', () => {
|
||||
assert.deepStrictEqual(
|
||||
parseConfig({
|
||||
INPUT_GENERATE_RELEASE_NOTES: "true",
|
||||
INPUT_GENERATE_RELEASE_NOTES: 'true',
|
||||
}),
|
||||
{
|
||||
github_ref: "",
|
||||
github_repository: "",
|
||||
github_token: "",
|
||||
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,
|
||||
@@ -210,24 +296,28 @@ describe("util", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("prefers GITHUB_TOKEN over token input for backwards compatibility", () => {
|
||||
it('prefers token input over GITHUB_TOKEN', () => {
|
||||
assert.deepStrictEqual(
|
||||
parseConfig({
|
||||
INPUT_DRAFT: "false",
|
||||
INPUT_PRERELEASE: "true",
|
||||
GITHUB_TOKEN: "env-token",
|
||||
INPUT_TOKEN: "input-token",
|
||||
INPUT_DRAFT: 'false',
|
||||
INPUT_PRERELEASE: 'true',
|
||||
INPUT_PRESERVE_ORDER: 'true',
|
||||
GITHUB_TOKEN: 'env-token',
|
||||
INPUT_TOKEN: 'input-token',
|
||||
}),
|
||||
{
|
||||
github_ref: "",
|
||||
github_repository: "",
|
||||
github_token: "env-token",
|
||||
github_ref: '',
|
||||
github_repository: '',
|
||||
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: true,
|
||||
input_files: [],
|
||||
input_overwrite_files: undefined,
|
||||
input_name: undefined,
|
||||
input_tag_name: undefined,
|
||||
input_fail_on_unmatched_files: false,
|
||||
@@ -238,99 +328,140 @@ describe("util", () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
it("uses input token as the source of GITHUB_TOKEN by default", () => {
|
||||
it('falls back to GITHUB_TOKEN when token input is empty', () => {
|
||||
assert.deepStrictEqual(
|
||||
parseConfig({
|
||||
INPUT_DRAFT: "false",
|
||||
INPUT_PRERELEASE: "true",
|
||||
INPUT_TOKEN: "input-token",
|
||||
GITHUB_TOKEN: 'env-token',
|
||||
INPUT_TOKEN: ' ',
|
||||
}),
|
||||
{
|
||||
github_ref: "",
|
||||
github_repository: "",
|
||||
github_token: "input-token",
|
||||
input_append_body: false,
|
||||
input_body: undefined,
|
||||
input_body_path: undefined,
|
||||
input_draft: false,
|
||||
input_prerelease: true,
|
||||
input_files: [],
|
||||
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("parses basic config with draft and prerelease", () => {
|
||||
assert.deepStrictEqual(
|
||||
parseConfig({
|
||||
INPUT_DRAFT: "false",
|
||||
INPUT_PRERELEASE: "true",
|
||||
}),
|
||||
{
|
||||
github_ref: "",
|
||||
github_repository: "",
|
||||
github_token: "",
|
||||
input_append_body: false,
|
||||
input_body: undefined,
|
||||
input_body_path: undefined,
|
||||
input_draft: false,
|
||||
input_prerelease: true,
|
||||
input_files: [],
|
||||
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("parses basic config where make_latest is passed", () => {
|
||||
assert.deepStrictEqual(
|
||||
parseConfig({
|
||||
INPUT_MAKE_LATEST: "false",
|
||||
}),
|
||||
{
|
||||
github_ref: "",
|
||||
github_repository: "",
|
||||
github_token: "",
|
||||
github_ref: '',
|
||||
github_repository: '',
|
||||
github_token: 'env-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: "false",
|
||||
input_make_latest: undefined,
|
||||
},
|
||||
);
|
||||
});
|
||||
it("parses basic config with append_body", () => {
|
||||
it('uses input token as the source of GITHUB_TOKEN by default', () => {
|
||||
assert.deepStrictEqual(
|
||||
parseConfig({
|
||||
INPUT_APPEND_BODY: "true",
|
||||
INPUT_DRAFT: 'false',
|
||||
INPUT_PRERELEASE: 'true',
|
||||
INPUT_TOKEN: 'input-token',
|
||||
}),
|
||||
{
|
||||
github_ref: "",
|
||||
github_repository: "",
|
||||
github_token: "",
|
||||
github_ref: '',
|
||||
github_repository: '',
|
||||
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,
|
||||
},
|
||||
);
|
||||
});
|
||||
it('parses basic config with draft and prerelease', () => {
|
||||
assert.deepStrictEqual(
|
||||
parseConfig({
|
||||
INPUT_DRAFT: 'false',
|
||||
INPUT_PRERELEASE: 'true',
|
||||
}),
|
||||
{
|
||||
github_ref: '',
|
||||
github_repository: '',
|
||||
github_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,
|
||||
},
|
||||
);
|
||||
});
|
||||
it('parses basic config where make_latest is passed', () => {
|
||||
assert.deepStrictEqual(
|
||||
parseConfig({
|
||||
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,
|
||||
input_make_latest: 'false',
|
||||
},
|
||||
);
|
||||
});
|
||||
it('parses basic config with append_body', () => {
|
||||
assert.deepStrictEqual(
|
||||
parseConfig({
|
||||
INPUT_APPEND_BODY: 'true',
|
||||
}),
|
||||
{
|
||||
github_ref: '',
|
||||
github_repository: '',
|
||||
github_token: '',
|
||||
input_working_directory: undefined,
|
||||
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,
|
||||
@@ -341,31 +472,151 @@ describe("util", () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
describe("isTag", () => {
|
||||
it("returns true for tags", async () => {
|
||||
assert.equal(isTag("refs/tags/foo"), true);
|
||||
|
||||
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');
|
||||
});
|
||||
it("returns false for other kinds of refs", async () => {
|
||||
assert.equal(isTag("refs/heads/master"), false);
|
||||
});
|
||||
describe('isTag', () => {
|
||||
it('returns true for tags', async () => {
|
||||
assert.equal(isTag('refs/tags/foo'), true);
|
||||
});
|
||||
it('returns false for other kinds of refs', async () => {
|
||||
assert.equal(isTag('refs/heads/master'), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("paths", () => {
|
||||
it("resolves files given a set of paths", async () => {
|
||||
assert.deepStrictEqual(
|
||||
paths(["tests/data/**/*", "tests/data/does/not/exist/*"]),
|
||||
["tests/data/foo/bar.txt"],
|
||||
);
|
||||
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("unmatchedPatterns", () => {
|
||||
describe('paths', () => {
|
||||
it('resolves files given a set of paths', async () => {
|
||||
assert.deepStrictEqual(paths(['tests/data/**/*', 'tests/data/does/not/exist/*']), [
|
||||
'tests/data/foo/bar.txt',
|
||||
]);
|
||||
});
|
||||
|
||||
it('resolves files relative to working_directory', async () => {
|
||||
assert.deepStrictEqual(paths(['data/**/*'], 'tests'), ['tests/data/foo/bar.txt']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('unmatchedPatterns', () => {
|
||||
it("returns the patterns that don't match any files", async () => {
|
||||
assert.deepStrictEqual(
|
||||
unmatchedPatterns(["tests/data/**/*", "tests/data/does/not/exist/*"]),
|
||||
["tests/data/does/not/exist/*"],
|
||||
unmatchedPatterns(['tests/data/**/*', 'tests/data/does/not/exist/*']),
|
||||
['tests/data/does/not/exist/*'],
|
||||
);
|
||||
});
|
||||
|
||||
it('resolves unmatched relative to working_directory', async () => {
|
||||
assert.deepStrictEqual(unmatchedPatterns(['data/does/not/exist/*'], 'tests'), [
|
||||
'data/does/not/exist/*',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
|
||||
it('handles names with multiple spaces', () => {
|
||||
expect(alignAssetName('John William Doe.bla')).toBe('John.William.Doe.bla');
|
||||
});
|
||||
|
||||
it('returns the same string if there are no spaces', () => {
|
||||
expect(alignAssetName('JohnDoe')).toBe('JohnDoe');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseInputFiles edge cases', () => {
|
||||
it('handles multiple brace groups on same line', () => {
|
||||
assert.deepStrictEqual(parseInputFiles('./**/*.{exe,deb},./dist/**/*.{zip,tar.gz}'), [
|
||||
'./**/*.{exe,deb}',
|
||||
'./dist/**/*.{zip,tar.gz}',
|
||||
]);
|
||||
});
|
||||
|
||||
it('handles nested braces', () => {
|
||||
assert.deepStrictEqual(parseInputFiles('path/{a,{b,c}}/file.txt'), ['path/{a,{b,c}}/file.txt']);
|
||||
});
|
||||
|
||||
it('handles empty comma-separated values', () => {
|
||||
assert.deepStrictEqual(parseInputFiles('foo,,bar'), ['foo', 'bar']);
|
||||
});
|
||||
|
||||
it('handles commas with spaces around braces', () => {
|
||||
assert.deepStrictEqual(parseInputFiles(' ./**/*.{exe,deb} , file.txt '), [
|
||||
'./**/*.{exe,deb}',
|
||||
'file.txt',
|
||||
]);
|
||||
});
|
||||
|
||||
it('handles mixed newlines and commas with braces', () => {
|
||||
assert.deepStrictEqual(parseInputFiles('file1.txt\n./**/*.{exe,deb},file2.txt\nfile3.txt'), [
|
||||
'file1.txt',
|
||||
'./**/*.{exe,deb}',
|
||||
'file2.txt',
|
||||
'file3.txt',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
20
action.yml
20
action.yml
@@ -13,7 +13,7 @@ inputs:
|
||||
description: "Gives the release a custom name. Defaults to tag name"
|
||||
required: false
|
||||
tag_name:
|
||||
description: "Gives a tag name. Defaults to github.GITHUB_REF"
|
||||
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"
|
||||
@@ -21,9 +21,19 @@ inputs:
|
||||
prerelease:
|
||||
description: "Identify the release as a prerelease. Defaults to false"
|
||||
required: false
|
||||
files:
|
||||
description: "Newline-delimited list of path globs for asset files to upload"
|
||||
preserve_order:
|
||||
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. 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)"
|
||||
required: false
|
||||
overwrite_files:
|
||||
description: "Overwrite existing files with the same name. Defaults to true"
|
||||
required: false
|
||||
default: 'true'
|
||||
fail_on_unmatched_files:
|
||||
description: "Fails if any of the `files` globs match nothing. Defaults to false"
|
||||
required: false
|
||||
@@ -31,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."
|
||||
|
||||
93
dist/index.js
vendored
93
dist/index.js
vendored
File diff suppressed because one or more lines are too long
@@ -1,16 +0,0 @@
|
||||
import type { JestConfigWithTsJest } from 'ts-jest';
|
||||
|
||||
const config: JestConfigWithTsJest = {
|
||||
preset: 'ts-jest/presets/default-esm',
|
||||
clearMocks: true,
|
||||
moduleFileExtensions: ['js', 'ts'],
|
||||
testEnvironment: 'node',
|
||||
testMatch: ['**/*.test.ts'],
|
||||
testRunner: 'jest-circus/runner',
|
||||
transform: {
|
||||
'^.+\\.ts$': 'ts-jest',
|
||||
},
|
||||
verbose: true,
|
||||
};
|
||||
|
||||
export default config;
|
||||
5788
package-lock.json
generated
5788
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
41
package.json
41
package.json
@@ -1,13 +1,14 @@
|
||||
{
|
||||
"name": "action-gh-release",
|
||||
"version": "2.0.8",
|
||||
"version": "2.5.3",
|
||||
"private": true,
|
||||
"description": "GitHub Action for creating GitHub Releases",
|
||||
"main": "lib/main.js",
|
||||
"scripts": {
|
||||
"build": "ncc build src/main.ts --minify",
|
||||
"build-debug": "ncc build src/main.ts --v8-cache --source-map",
|
||||
"test": "jest",
|
||||
"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",
|
||||
"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"
|
||||
@@ -21,25 +22,23 @@
|
||||
],
|
||||
"author": "softprops",
|
||||
"dependencies": {
|
||||
"@actions/core": "^1.10.0",
|
||||
"@actions/github": "^6.0.0",
|
||||
"@octokit/plugin-retry": "^7.1.1",
|
||||
"@octokit/plugin-throttling": "^9.3.1",
|
||||
"glob": "^11.0.0",
|
||||
"mime": "^3.0.0"
|
||||
"@actions/core": "^3.0.0",
|
||||
"@actions/github": "^9.0.0",
|
||||
"@octokit/plugin-retry": "^8.1.0",
|
||||
"@octokit/plugin-throttling": "^11.0.3",
|
||||
"glob": "^13.0.6",
|
||||
"mime-types": "^3.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/glob": "^8.1.0",
|
||||
"@types/jest": "^29.5.12",
|
||||
"@types/mime": "^3.0.1",
|
||||
"@types/node": "^20.14.11",
|
||||
"@vercel/ncc": "^0.38.1",
|
||||
"jest": "^29.3.1",
|
||||
"jest-circus": "^29.3.1",
|
||||
"prettier": "3.3.3",
|
||||
"ts-jest": "^29.2.3",
|
||||
"@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.5.3",
|
||||
"typescript-formatter": "^7.2.2"
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-formatter": "^7.2.2",
|
||||
"vitest": "^4.0.4"
|
||||
}
|
||||
}
|
||||
|
||||
715
src/github.ts
715
src/github.ts
@@ -1,8 +1,9 @@
|
||||
import { GitHub } from "@actions/github/lib/utils";
|
||||
import { Config, isTag, releaseBody } from "./util";
|
||||
import { statSync, readFileSync } from "fs";
|
||||
import { getType } from "mime";
|
||||
import { basename } from "path";
|
||||
import { GitHub } from '@actions/github/lib/utils';
|
||||
import { statSync } from 'fs';
|
||||
import { open } from 'fs/promises';
|
||||
import { lookup } from 'mime-types';
|
||||
import { basename } from 'path';
|
||||
import { alignAssetName, Config, isTag, normalizeTagName, releaseBody } from './util';
|
||||
|
||||
type GitHub = InstanceType<typeof GitHub>;
|
||||
|
||||
@@ -10,7 +11,6 @@ export interface ReleaseAsset {
|
||||
name: string;
|
||||
mime: string;
|
||||
size: number;
|
||||
data: Buffer;
|
||||
}
|
||||
|
||||
export interface Release {
|
||||
@@ -23,15 +23,16 @@ export interface Release {
|
||||
target_commitish: string;
|
||||
draft: boolean;
|
||||
prerelease: boolean;
|
||||
assets: Array<{ id: number; name: string }>;
|
||||
assets: Array<{ id: number; name: string; label?: string | null }>;
|
||||
}
|
||||
|
||||
export interface ReleaseResult {
|
||||
release: Release;
|
||||
created: boolean;
|
||||
}
|
||||
|
||||
export interface Releaser {
|
||||
getReleaseByTag(params: {
|
||||
owner: string;
|
||||
repo: string;
|
||||
tag: string;
|
||||
}): Promise<{ data: Release }>;
|
||||
getReleaseByTag(params: { owner: string; repo: string; tag: string }): Promise<{ data: Release }>;
|
||||
|
||||
createRelease(params: {
|
||||
owner: string;
|
||||
@@ -44,7 +45,7 @@ export interface Releaser {
|
||||
target_commitish: string | undefined;
|
||||
discussion_category_name: string | undefined;
|
||||
generate_release_notes: boolean | undefined;
|
||||
make_latest: "true" | "false" | "legacy" | undefined;
|
||||
make_latest: 'true' | 'false' | 'legacy' | undefined;
|
||||
}): Promise<{ data: Release }>;
|
||||
|
||||
updateRelease(params: {
|
||||
@@ -59,13 +60,43 @@ export interface Releaser {
|
||||
prerelease: boolean | undefined;
|
||||
discussion_category_name: string | undefined;
|
||||
generate_release_notes: boolean | undefined;
|
||||
make_latest: "true" | "false" | "legacy" | undefined;
|
||||
make_latest: 'true' | 'false' | 'legacy' | undefined;
|
||||
}): Promise<{ data: Release }>;
|
||||
|
||||
allReleases(params: {
|
||||
finalizeRelease(params: {
|
||||
owner: string;
|
||||
repo: string;
|
||||
}): AsyncIterableIterator<{ data: Release[] }>;
|
||||
release_id: number;
|
||||
make_latest: 'true' | 'false' | 'legacy' | undefined;
|
||||
}): Promise<{ data: Release }>;
|
||||
|
||||
allReleases(params: { owner: string; repo: string }): AsyncIterable<{ data: Release[] }>;
|
||||
|
||||
listReleaseAssets(params: {
|
||||
owner: string;
|
||||
repo: string;
|
||||
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>;
|
||||
|
||||
deleteRelease(params: { owner: string; repo: string; release_id: number }): Promise<void>;
|
||||
|
||||
updateReleaseAsset(params: {
|
||||
owner: string;
|
||||
repo: string;
|
||||
asset_id: number;
|
||||
name: string;
|
||||
label: string;
|
||||
}): Promise<{ data: any }>;
|
||||
|
||||
uploadReleaseAsset(params: {
|
||||
url: string;
|
||||
size: number;
|
||||
mime: string;
|
||||
token: string;
|
||||
data: any;
|
||||
}): Promise<{ status: number; data: any }>;
|
||||
}
|
||||
|
||||
export class GitHubReleaser implements Releaser {
|
||||
@@ -82,7 +113,27 @@ export class GitHubReleaser implements Releaser {
|
||||
return this.github.rest.repos.getReleaseByTag(params);
|
||||
}
|
||||
|
||||
createRelease(params: {
|
||||
async getReleaseNotes(params: {
|
||||
owner: string;
|
||||
repo: string;
|
||||
tag_name: string;
|
||||
target_commitish: string | undefined;
|
||||
}): Promise<{
|
||||
data: {
|
||||
name: string;
|
||||
body: string;
|
||||
};
|
||||
}> {
|
||||
return await this.github.rest.repos.generateReleaseNotes(params);
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -93,19 +144,28 @@ export class GitHubReleaser implements Releaser {
|
||||
target_commitish: string | undefined;
|
||||
discussion_category_name: string | undefined;
|
||||
generate_release_notes: boolean | undefined;
|
||||
make_latest: "true" | "false" | "legacy" | undefined;
|
||||
make_latest: 'true' | 'false' | 'legacy' | undefined;
|
||||
}): Promise<{ data: Release }> {
|
||||
if (
|
||||
typeof params.make_latest === "string" &&
|
||||
!["true", "false", "legacy"].includes(params.make_latest)
|
||||
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);
|
||||
}
|
||||
|
||||
updateRelease(params: {
|
||||
async updateRelease(params: {
|
||||
owner: string;
|
||||
repo: string;
|
||||
release_id: number;
|
||||
@@ -117,27 +177,100 @@ export class GitHubReleaser implements Releaser {
|
||||
prerelease: boolean | undefined;
|
||||
discussion_category_name: string | undefined;
|
||||
generate_release_notes: boolean | undefined;
|
||||
make_latest: "true" | "false" | "legacy" | undefined;
|
||||
make_latest: 'true' | 'false' | 'legacy' | undefined;
|
||||
}): Promise<{ data: Release }> {
|
||||
if (
|
||||
typeof params.make_latest === "string" &&
|
||||
!["true", "false", "legacy"].includes(params.make_latest)
|
||||
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);
|
||||
}
|
||||
|
||||
allReleases(params: {
|
||||
async finalizeRelease(params: {
|
||||
owner: string;
|
||||
repo: string;
|
||||
}): AsyncIterableIterator<{ data: Release[] }> {
|
||||
release_id: number;
|
||||
make_latest: 'true' | 'false' | 'legacy' | undefined;
|
||||
}) {
|
||||
return await this.github.rest.repos.updateRelease({
|
||||
owner: params.owner,
|
||||
repo: params.repo,
|
||||
release_id: params.release_id,
|
||||
draft: false,
|
||||
make_latest: params.make_latest,
|
||||
});
|
||||
}
|
||||
|
||||
allReleases(params: { owner: string; repo: string }): AsyncIterable<{ data: Release[] }> {
|
||||
const updatedParams = { per_page: 100, ...params };
|
||||
return this.github.paginate.iterator(
|
||||
this.github.rest.repos.listReleases.endpoint.merge(updatedParams),
|
||||
);
|
||||
}
|
||||
|
||||
async listReleaseAssets(params: {
|
||||
owner: string;
|
||||
repo: string;
|
||||
release_id: number;
|
||||
}): Promise<Array<{ id: number; name: string; label?: string | null; [key: string]: any }>> {
|
||||
return this.github.paginate(this.github.rest.repos.listReleaseAssets, {
|
||||
...params,
|
||||
per_page: 100,
|
||||
});
|
||||
}
|
||||
|
||||
async deleteReleaseAsset(params: {
|
||||
owner: string;
|
||||
repo: string;
|
||||
asset_id: number;
|
||||
}): Promise<void> {
|
||||
await this.github.rest.repos.deleteReleaseAsset(params);
|
||||
}
|
||||
|
||||
async deleteRelease(params: { owner: string; repo: string; release_id: number }): Promise<void> {
|
||||
await this.github.rest.repos.deleteRelease(params);
|
||||
}
|
||||
|
||||
async updateReleaseAsset(params: {
|
||||
owner: string;
|
||||
repo: string;
|
||||
asset_id: number;
|
||||
name: string;
|
||||
label: string;
|
||||
}): Promise<{ data: any }> {
|
||||
return await this.github.rest.repos.updateReleaseAsset(params);
|
||||
}
|
||||
|
||||
async uploadReleaseAsset(params: {
|
||||
url: string;
|
||||
size: number;
|
||||
mime: string;
|
||||
token: string;
|
||||
data: any;
|
||||
}): Promise<{ status: number; data: any }> {
|
||||
return this.github.request({
|
||||
method: 'POST',
|
||||
url: params.url,
|
||||
headers: {
|
||||
'content-length': `${params.size}`,
|
||||
'content-type': params.mime,
|
||||
authorization: `token ${params.token}`,
|
||||
},
|
||||
data: params.data,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const asset = (path: string): ReleaseAsset => {
|
||||
@@ -145,101 +278,157 @@ export const asset = (path: string): ReleaseAsset => {
|
||||
name: basename(path),
|
||||
mime: mimeOrDefault(path),
|
||||
size: statSync(path).size,
|
||||
data: readFileSync(path),
|
||||
};
|
||||
};
|
||||
|
||||
export const mimeOrDefault = (path: string): string => {
|
||||
return getType(path) || "application/octet-stream";
|
||||
return lookup(path) || 'application/octet-stream';
|
||||
};
|
||||
|
||||
export const upload = async (
|
||||
config: Config,
|
||||
github: GitHub,
|
||||
releaser: Releaser,
|
||||
url: string,
|
||||
path: string,
|
||||
currentAssets: Array<{ id: number; name: string }>,
|
||||
currentAssets: Array<{ id: number; name: string; label?: string | null }>,
|
||||
): Promise<any> => {
|
||||
const [owner, repo] = config.github_repository.split("/");
|
||||
const { name, size, mime, data: body } = asset(path);
|
||||
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 }) => currentName == name.replace(" ", "."),
|
||||
({ name: currentName, label: currentLabel }) =>
|
||||
currentName === name || currentName === alignAssetName(name) || currentLabel === name,
|
||||
);
|
||||
if (currentAsset) {
|
||||
console.log(`♻️ Deleting previously uploaded asset ${name}...`);
|
||||
await github.rest.repos.deleteReleaseAsset({
|
||||
asset_id: currentAsset.id || 1,
|
||||
owner,
|
||||
repo,
|
||||
});
|
||||
if (config.input_overwrite_files === false) {
|
||||
console.log(`Asset ${name} already exists and overwrite_files is false...`);
|
||||
return null;
|
||||
} else {
|
||||
console.log(`♻️ Deleting previously uploaded asset ${name}...`);
|
||||
await releaser.deleteReleaseAsset({
|
||||
asset_id: currentAsset.id || 1,
|
||||
owner,
|
||||
repo,
|
||||
});
|
||||
}
|
||||
}
|
||||
console.log(`⬆️ Uploading ${name}...`);
|
||||
const endpoint = new URL(url);
|
||||
endpoint.searchParams.append("name", name);
|
||||
const resp = await github.request({
|
||||
method: "POST",
|
||||
url: endpoint.toString(),
|
||||
headers: {
|
||||
"content-length": `${size}`,
|
||||
"content-type": mime,
|
||||
authorization: `token ${config.github_token}`,
|
||||
},
|
||||
data: body,
|
||||
});
|
||||
const json = resp.data;
|
||||
if (resp.status !== 201) {
|
||||
throw new Error(
|
||||
`Failed to upload release asset ${name}. received status code ${
|
||||
resp.status
|
||||
}\n${json.message}\n${JSON.stringify(json.errors)}`,
|
||||
);
|
||||
endpoint.searchParams.append('name', name);
|
||||
const uploadAsset = async () => {
|
||||
const fh = await open(path);
|
||||
try {
|
||||
return await releaser.uploadReleaseAsset({
|
||||
url: endpoint.toString(),
|
||||
size,
|
||||
mime,
|
||||
token: config.github_token,
|
||||
data: fh.readableWebStream({ type: 'bytes' }),
|
||||
});
|
||||
} finally {
|
||||
await fh.close();
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const resp = await uploadAsset();
|
||||
const json = resp.data;
|
||||
if (resp.status !== 201) {
|
||||
throw new Error(
|
||||
`Failed to upload release asset ${name}. received status code ${
|
||||
resp.status
|
||||
}\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}`);
|
||||
}
|
||||
}
|
||||
console.log(`✅ Uploaded ${name}`);
|
||||
return json;
|
||||
} catch (error: any) {
|
||||
const errorStatus = error?.status ?? error?.response?.status;
|
||||
const errorData = error?.response?.data;
|
||||
|
||||
// Handle race conditions across concurrent workflows uploading the same asset.
|
||||
if (
|
||||
config.input_overwrite_files !== false &&
|
||||
errorStatus === 422 &&
|
||||
errorData?.errors?.[0]?.code === 'already_exists' &&
|
||||
releaseId !== undefined
|
||||
) {
|
||||
console.log(
|
||||
`⚠️ Asset ${name} already exists (race condition), refreshing assets and retrying once...`,
|
||||
);
|
||||
const latestAssets = await releaser.listReleaseAssets({
|
||||
owner,
|
||||
repo,
|
||||
release_id: releaseId,
|
||||
});
|
||||
const latestAsset = latestAssets.find(
|
||||
({ name: currentName }) => currentName == alignAssetName(name),
|
||||
);
|
||||
if (latestAsset) {
|
||||
await releaser.deleteReleaseAsset({
|
||||
owner,
|
||||
repo,
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
return json;
|
||||
};
|
||||
|
||||
export const release = async (
|
||||
config: Config,
|
||||
releaser: Releaser,
|
||||
maxRetries: number = 3,
|
||||
): Promise<Release> => {
|
||||
): Promise<ReleaseResult> => {
|
||||
if (maxRetries <= 0) {
|
||||
console.log(`❌ Too many retries. Aborting...`);
|
||||
throw new Error("Too many retries.");
|
||||
throw new Error('Too many retries.');
|
||||
}
|
||||
|
||||
const [owner, repo] = config.github_repository.split("/");
|
||||
const [owner, repo] = config.github_repository.split('/');
|
||||
const tag =
|
||||
config.input_tag_name ||
|
||||
(isTag(config.github_ref)
|
||||
? config.github_ref.replace("refs/tags/", "")
|
||||
: "");
|
||||
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;
|
||||
try {
|
||||
// you can't get a an existing draft by tag
|
||||
// so we must find one in the list of all releases
|
||||
let _release: Release | undefined = undefined;
|
||||
if (config.input_draft) {
|
||||
for await (const response of releaser.allReleases({
|
||||
owner,
|
||||
repo,
|
||||
})) {
|
||||
_release = response.data.find((release) => release.tag_name === tag);
|
||||
}
|
||||
} else {
|
||||
_release = (
|
||||
await releaser.getReleaseByTag({
|
||||
owner,
|
||||
repo,
|
||||
tag,
|
||||
})
|
||||
).data;
|
||||
}
|
||||
if (_release === null || _release === undefined) {
|
||||
const _release: Release | undefined = await findTagFromReleases(releaser, owner, repo, tag);
|
||||
|
||||
if (_release === undefined) {
|
||||
return await createRelease(
|
||||
tag,
|
||||
config,
|
||||
@@ -253,9 +442,7 @@ export const release = async (
|
||||
}
|
||||
|
||||
let existingRelease: Release = _release!;
|
||||
console.log(
|
||||
`Found release ${existingRelease.name} (with id=${existingRelease.id})`,
|
||||
);
|
||||
console.log(`Found release ${existingRelease.name} (with id=${existingRelease.id})`);
|
||||
|
||||
const release_id = existingRelease.id;
|
||||
let target_commitish: string;
|
||||
@@ -277,23 +464,17 @@ export const release = async (
|
||||
// body parts as a release gets updated. some users will likely want this while
|
||||
// others won't previously this was duplicating content for most which
|
||||
// no one wants
|
||||
const workflowBody = releaseBody(config) || "";
|
||||
const existingReleaseBody = existingRelease.body || "";
|
||||
const workflowBody = releaseBody(config) || '';
|
||||
const existingReleaseBody = existingRelease.body || '';
|
||||
let body: string;
|
||||
if (config.input_append_body && workflowBody && existingReleaseBody) {
|
||||
body = existingReleaseBody + "\n" + workflowBody;
|
||||
body = existingReleaseBody + '\n' + workflowBody;
|
||||
} else {
|
||||
body = workflowBody || existingReleaseBody;
|
||||
}
|
||||
|
||||
const draft =
|
||||
config.input_draft !== undefined
|
||||
? config.input_draft
|
||||
: existingRelease.draft;
|
||||
const prerelease =
|
||||
config.input_prerelease !== undefined
|
||||
? config.input_prerelease
|
||||
: existingRelease.prerelease;
|
||||
config.input_prerelease !== undefined ? config.input_prerelease : existingRelease.prerelease;
|
||||
|
||||
const make_latest = config.input_make_latest;
|
||||
|
||||
@@ -305,13 +486,16 @@ export const release = async (
|
||||
target_commitish,
|
||||
name,
|
||||
body,
|
||||
draft,
|
||||
draft: existingRelease.draft,
|
||||
prerelease,
|
||||
discussion_category_name,
|
||||
generate_release_notes,
|
||||
make_latest,
|
||||
});
|
||||
return release.data;
|
||||
return {
|
||||
release: release.data,
|
||||
created: false,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error.status !== 404) {
|
||||
console.log(
|
||||
@@ -333,6 +517,276 @@ export const release = async (
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Finalizes a release by unmarking it as "draft" (if relevant)
|
||||
* after all artifacts have been uploaded.
|
||||
*
|
||||
* @param config - Release configuration as specified by user
|
||||
* @param releaser - The GitHub API wrapper for release operations
|
||||
* @param release - The existing release to be finalized
|
||||
* @param maxRetries - The maximum number of attempts to finalize the release
|
||||
*/
|
||||
export const finalizeRelease = async (
|
||||
config: Config,
|
||||
releaser: Releaser,
|
||||
release: Release,
|
||||
releaseWasCreated: boolean = false,
|
||||
maxRetries: number = 3,
|
||||
): Promise<Release> => {
|
||||
if (config.input_draft === true || release.draft === false) {
|
||||
return release;
|
||||
}
|
||||
|
||||
if (maxRetries <= 0) {
|
||||
console.log(`❌ Too many retries. Aborting...`);
|
||||
throw new Error('Too many retries.');
|
||||
}
|
||||
|
||||
const [owner, repo] = config.github_repository.split('/');
|
||||
try {
|
||||
const { data } = await releaser.finalizeRelease({
|
||||
owner,
|
||||
repo,
|
||||
release_id: release.id,
|
||||
make_latest: config.input_make_latest,
|
||||
});
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.warn(`error finalizing release: ${error}`);
|
||||
|
||||
if (releaseWasCreated && release.draft && isTagCreationBlockedError(error)) {
|
||||
let deleted = false;
|
||||
|
||||
try {
|
||||
console.log(
|
||||
`🧹 Deleting draft release ${release.id} for tag ${release.tag_name} because tag creation is blocked by repository rules...`,
|
||||
);
|
||||
await releaser.deleteRelease({
|
||||
owner,
|
||||
repo,
|
||||
release_id: release.id,
|
||||
});
|
||||
deleted = true;
|
||||
} catch (cleanupError) {
|
||||
console.warn(`error deleting orphan draft release ${release.id}: ${cleanupError}`);
|
||||
}
|
||||
|
||||
const cleanupResult = deleted
|
||||
? `Deleted draft release ${release.id} to avoid leaving an orphaned draft release.`
|
||||
: `Failed to delete draft release ${release.id}; manual cleanup may still be required.`;
|
||||
throw new Error(
|
||||
`Tag creation for ${release.tag_name} is blocked by repository rules. ${cleanupResult}`,
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`retrying... (${maxRetries - 1} retries remaining)`);
|
||||
return finalizeRelease(config, releaser, release, releaseWasCreated, maxRetries - 1);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Lists assets belonging to a release.
|
||||
*
|
||||
* @param config - Release configuration as specified by user
|
||||
* @param releaser - The GitHub API wrapper for release operations
|
||||
* @param release - The existing release to be checked
|
||||
* @param maxRetries - The maximum number of attempts
|
||||
*/
|
||||
export const listReleaseAssets = async (
|
||||
config: Config,
|
||||
releaser: Releaser,
|
||||
release: Release,
|
||||
maxRetries: number = 3,
|
||||
): Promise<Array<{ id: number; name: string; [key: string]: any }>> => {
|
||||
if (maxRetries <= 0) {
|
||||
console.log(`❌ Too many retries. Aborting...`);
|
||||
throw new Error('Too many retries.');
|
||||
}
|
||||
|
||||
const [owner, repo] = config.github_repository.split('/');
|
||||
try {
|
||||
const assets = await releaser.listReleaseAssets({
|
||||
owner,
|
||||
repo,
|
||||
release_id: release.id,
|
||||
});
|
||||
|
||||
return assets;
|
||||
} catch (error) {
|
||||
console.warn(`error listing assets of release: ${error}`);
|
||||
console.log(`retrying... (${maxRetries - 1} retries remaining)`);
|
||||
return listReleaseAssets(config, releaser, release, maxRetries - 1);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* @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
|
||||
* @returns The release with the given tag name, or undefined if no release with that tag name is found
|
||||
*/
|
||||
export async function findTagFromReleases(
|
||||
releaser: Releaser,
|
||||
owner: string,
|
||||
repo: string,
|
||||
tag: string,
|
||||
): 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;
|
||||
}
|
||||
// Re-throw unexpected errors
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const CREATED_RELEASE_DISCOVERY_RETRY_DELAY_MS = 1000;
|
||||
const RECENT_RELEASE_SCAN_PAGES = 2;
|
||||
|
||||
async function sleep(ms: number): Promise<void> {
|
||||
await new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function recentReleasesByTag(
|
||||
releaser: Releaser,
|
||||
owner: string,
|
||||
repo: string,
|
||||
tag: string,
|
||||
): Promise<Release[]> {
|
||||
const matches: Release[] = [];
|
||||
let pages = 0;
|
||||
|
||||
for await (const page of releaser.allReleases({ owner, repo })) {
|
||||
matches.push(...page.data.filter((release) => release.tag_name === tag));
|
||||
pages += 1;
|
||||
|
||||
if (pages >= RECENT_RELEASE_SCAN_PAGES) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
function pickCanonicalRelease(
|
||||
releases: Release[],
|
||||
releaseByTag: Release | undefined,
|
||||
): Release | undefined {
|
||||
if (releaseByTag && releases.some((release) => release.id === releaseByTag.id)) {
|
||||
return releaseByTag;
|
||||
}
|
||||
|
||||
if (releases.length === 0) {
|
||||
return releaseByTag;
|
||||
}
|
||||
|
||||
return [...releases].sort((left, right) => {
|
||||
if (left.draft !== right.draft) {
|
||||
return Number(left.draft) - Number(right.draft);
|
||||
}
|
||||
|
||||
return left.id - right.id;
|
||||
})[0];
|
||||
}
|
||||
|
||||
async function cleanupDuplicateDraftReleases(
|
||||
releaser: Releaser,
|
||||
owner: string,
|
||||
repo: string,
|
||||
tag: string,
|
||||
canonicalReleaseId: number,
|
||||
releases: Release[],
|
||||
): Promise<void> {
|
||||
const uniqueReleases = Array.from(
|
||||
new Map(releases.map((release) => [release.id, release])).values(),
|
||||
);
|
||||
|
||||
for (const duplicate of uniqueReleases) {
|
||||
if (duplicate.id === canonicalReleaseId || !duplicate.draft || duplicate.assets.length > 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`🧹 Removing duplicate draft release ${duplicate.id} for tag ${tag}...`);
|
||||
await releaser.deleteRelease({
|
||||
owner,
|
||||
repo,
|
||||
release_id: duplicate.id,
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn(`error deleting duplicate release ${duplicate.id}: ${error}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function canonicalizeCreatedRelease(
|
||||
releaser: Releaser,
|
||||
owner: string,
|
||||
repo: string,
|
||||
tag: string,
|
||||
createdRelease: Release,
|
||||
maxRetries: number,
|
||||
): Promise<Release> {
|
||||
const attempts = Math.max(maxRetries, 1);
|
||||
|
||||
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
||||
let releaseByTag: Release | undefined;
|
||||
try {
|
||||
releaseByTag = await findTagFromReleases(releaser, owner, repo, tag);
|
||||
} catch (error) {
|
||||
console.warn(`error reloading release for tag ${tag}: ${error}`);
|
||||
}
|
||||
|
||||
let recentReleases: Release[] = [];
|
||||
try {
|
||||
recentReleases = await recentReleasesByTag(releaser, owner, repo, tag);
|
||||
} catch (error) {
|
||||
console.warn(`error listing recent releases for tag ${tag}: ${error}`);
|
||||
}
|
||||
|
||||
const canonicalRelease = pickCanonicalRelease(recentReleases, releaseByTag);
|
||||
if (canonicalRelease) {
|
||||
if (canonicalRelease.id !== createdRelease.id) {
|
||||
console.log(
|
||||
`↪️ Using release ${canonicalRelease.id} for tag ${tag} instead of duplicate draft ${createdRelease.id}`,
|
||||
);
|
||||
}
|
||||
|
||||
await cleanupDuplicateDraftReleases(releaser, owner, repo, tag, canonicalRelease.id, [
|
||||
createdRelease,
|
||||
...recentReleases,
|
||||
]);
|
||||
return canonicalRelease;
|
||||
}
|
||||
|
||||
if (attempt < attempts) {
|
||||
console.log(
|
||||
`Release ${createdRelease.id} is not yet discoverable by tag ${tag}, retrying... (${
|
||||
attempts - attempt
|
||||
} retries remaining)`,
|
||||
);
|
||||
await sleep(CREATED_RELEASE_DISCOVERY_RETRY_DELAY_MS);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
`⚠️ Continuing with newly created release ${createdRelease.id} because tag ${tag} is still not discoverable`,
|
||||
);
|
||||
return createdRelease;
|
||||
}
|
||||
|
||||
async function createRelease(
|
||||
tag: string,
|
||||
config: Config,
|
||||
@@ -342,23 +796,21 @@ async function createRelease(
|
||||
discussion_category_name: string | undefined,
|
||||
generate_release_notes: boolean | undefined,
|
||||
maxRetries: number,
|
||||
) {
|
||||
): Promise<ReleaseResult> {
|
||||
const tag_name = tag;
|
||||
const name = config.input_name || tag;
|
||||
const body = releaseBody(config);
|
||||
const draft = config.input_draft;
|
||||
const prerelease = config.input_prerelease;
|
||||
const draft = prerelease === true ? config.input_draft === true : true;
|
||||
const target_commitish = config.input_target_commitish;
|
||||
const make_latest = config.input_make_latest;
|
||||
let commitMessage: string = "";
|
||||
let commitMessage: string = '';
|
||||
if (target_commitish) {
|
||||
commitMessage = ` using commit "${target_commitish}"`;
|
||||
}
|
||||
console.log(
|
||||
`👩🏭 Creating new GitHub release for tag ${tag_name}${commitMessage}...`,
|
||||
);
|
||||
console.log(`👩🏭 Creating new GitHub release for tag ${tag_name}${commitMessage}...`);
|
||||
try {
|
||||
let release = await releaser.createRelease({
|
||||
const createdRelease = await releaser.createRelease({
|
||||
owner,
|
||||
repo,
|
||||
tag_name,
|
||||
@@ -371,7 +823,18 @@ async function createRelease(
|
||||
generate_release_notes,
|
||||
make_latest,
|
||||
});
|
||||
return release.data;
|
||||
const canonicalRelease = await canonicalizeCreatedRelease(
|
||||
releaser,
|
||||
owner,
|
||||
repo,
|
||||
tag_name,
|
||||
createdRelease.data,
|
||||
maxRetries,
|
||||
);
|
||||
return {
|
||||
release: canonicalRelease,
|
||||
created: canonicalRelease.id === createdRelease.data.id,
|
||||
};
|
||||
} catch (error) {
|
||||
// presume a race with competing matrix runs
|
||||
console.log(`⚠️ GitHub release failed with status: ${error.status}`);
|
||||
@@ -380,20 +843,44 @@ async function createRelease(
|
||||
switch (error.status) {
|
||||
case 403:
|
||||
console.log(
|
||||
"Skip retry — your GitHub token/PAT does not have the required permission to create a release",
|
||||
'Skip retry — your GitHub token/PAT does not have the required permission to create a release',
|
||||
);
|
||||
throw error;
|
||||
|
||||
case 404:
|
||||
console.log("Skip retry - discussion category mismatch");
|
||||
console.log('Skip retry - discussion category mismatch');
|
||||
throw error;
|
||||
|
||||
case 422:
|
||||
console.log("Skip retry - validation failed");
|
||||
throw error;
|
||||
// Check if this is a race condition with "already_exists" error
|
||||
const errorData = error.response?.data;
|
||||
if (errorData?.errors?.[0]?.code === 'already_exists') {
|
||||
console.log(
|
||||
'⚠️ Release already exists (race condition detected), retrying to find and update existing release...',
|
||||
);
|
||||
// Don't throw - allow retry to find existing release
|
||||
} else {
|
||||
console.log('Skip retry - validation failed');
|
||||
throw error;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
console.log(`retrying... (${maxRetries - 1} retries remaining)`);
|
||||
return release(config, releaser, maxRetries - 1);
|
||||
}
|
||||
}
|
||||
|
||||
function isTagCreationBlockedError(error: any): boolean {
|
||||
const errors = error?.response?.data?.errors;
|
||||
if (!Array.isArray(errors) || error?.status !== 422) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return errors.some(
|
||||
({ field, message }: { field?: string; message?: string }) =>
|
||||
field === 'pre_receive' &&
|
||||
typeof message === 'string' &&
|
||||
message.includes('creations being restricted'),
|
||||
);
|
||||
}
|
||||
|
||||
105
src/main.ts
105
src/main.ts
@@ -1,29 +1,18 @@
|
||||
import {
|
||||
paths,
|
||||
parseConfig,
|
||||
isTag,
|
||||
unmatchedPatterns,
|
||||
uploadUrl,
|
||||
} from "./util";
|
||||
import { release, upload, GitHubReleaser } from "./github";
|
||||
import { getOctokit } from "@actions/github";
|
||||
import { setFailed, setOutput } from "@actions/core";
|
||||
import { GitHub, getOctokitOptions } from "@actions/github/lib/utils";
|
||||
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 { env } from "process";
|
||||
import { env } from 'process';
|
||||
|
||||
async function run() {
|
||||
try {
|
||||
const config = parseConfig(env);
|
||||
if (
|
||||
!config.input_tag_name &&
|
||||
!isTag(config.github_ref) &&
|
||||
!config.input_draft
|
||||
) {
|
||||
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);
|
||||
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.`);
|
||||
@@ -45,9 +34,7 @@ async function run() {
|
||||
//new oktokit(
|
||||
throttle: {
|
||||
onRateLimit: (retryAfter, options) => {
|
||||
console.warn(
|
||||
`Request quota exhausted for request ${options.method} ${options.url}`,
|
||||
);
|
||||
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!`);
|
||||
@@ -56,45 +43,69 @@ async function run() {
|
||||
},
|
||||
onAbuseLimit: (retryAfter, options) => {
|
||||
// does not retry, only logs a warning
|
||||
console.warn(
|
||||
`Abuse detected for request ${options.method} ${options.url}`,
|
||||
);
|
||||
console.warn(`Abuse detected for request ${options.method} ${options.url}`);
|
||||
},
|
||||
},
|
||||
});
|
||||
//);
|
||||
const rel = await release(config, new GitHubReleaser(gh));
|
||||
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);
|
||||
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} not include valid file.`);
|
||||
throw new Error(`⚠️ ${config.input_files} does not include a valid file.`);
|
||||
} else {
|
||||
console.warn(`🤔 ${config.input_files} not include valid file.`);
|
||||
console.warn(`🤔 ${config.input_files} does not include a valid file.`);
|
||||
}
|
||||
}
|
||||
const currentAssets = rel.assets;
|
||||
const assets = await Promise.all(
|
||||
files.map(async (path) => {
|
||||
const json = await upload(
|
||||
config,
|
||||
gh,
|
||||
uploadUrl(rel.upload_url),
|
||||
path,
|
||||
currentAssets,
|
||||
);
|
||||
delete json.uploader;
|
||||
return json;
|
||||
}),
|
||||
).catch((error) => {
|
||||
throw error;
|
||||
});
|
||||
setOutput("assets", 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);
|
||||
setOutput('url', rel.html_url);
|
||||
setOutput('id', rel.id.toString());
|
||||
setOutput('upload_url', rel.upload_url);
|
||||
} catch (error) {
|
||||
setFailed(error.message);
|
||||
}
|
||||
|
||||
184
src/util.ts
184
src/util.ts
@@ -1,5 +1,7 @@
|
||||
import * as glob from "glob";
|
||||
import { statSync, readFileSync } from "fs";
|
||||
import * as glob from 'glob';
|
||||
import { statSync, readFileSync } from 'fs';
|
||||
import { homedir } from 'os';
|
||||
import * as pathLib from 'path';
|
||||
|
||||
export interface Config {
|
||||
github_token: string;
|
||||
@@ -12,18 +14,21 @@ export interface Config {
|
||||
input_body?: string;
|
||||
input_body_path?: string;
|
||||
input_files?: string[];
|
||||
input_working_directory?: string;
|
||||
input_overwrite_files?: boolean;
|
||||
input_draft?: boolean;
|
||||
input_preserve_order?: boolean;
|
||||
input_prerelease?: boolean;
|
||||
input_fail_on_unmatched_files?: boolean;
|
||||
input_target_commitish?: string;
|
||||
input_discussion_category_name?: string;
|
||||
input_generate_release_notes?: boolean;
|
||||
input_append_body?: boolean;
|
||||
input_make_latest: "true" | "false" | "legacy" | undefined;
|
||||
input_make_latest: 'true' | 'false' | 'legacy' | undefined;
|
||||
}
|
||||
|
||||
export const uploadUrl = (url: string): string => {
|
||||
const templateMarkerPos = url.indexOf("{");
|
||||
const templateMarkerPos = url.indexOf('{');
|
||||
if (templateMarkerPos > -1) {
|
||||
return url.substring(0, templateMarkerPos);
|
||||
}
|
||||
@@ -31,77 +36,166 @@ export const uploadUrl = (url: string): string => {
|
||||
};
|
||||
|
||||
export const releaseBody = (config: Config): string | undefined => {
|
||||
return (
|
||||
(config.input_body_path &&
|
||||
readFileSync(config.input_body_path).toString("utf8")) ||
|
||||
config.input_body
|
||||
);
|
||||
if (config.input_body_path) {
|
||||
try {
|
||||
const contents = readFileSync(config.input_body_path, 'utf8');
|
||||
return contents;
|
||||
} catch (err: any) {
|
||||
console.warn(
|
||||
`⚠️ Failed to read body_path "${config.input_body_path}" (${err?.code ?? 'ERR'}). Falling back to 'body' input.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return config.input_body;
|
||||
};
|
||||
|
||||
type Env = { [key: string]: string | undefined };
|
||||
|
||||
const smartSplit = (input: string): string[] => {
|
||||
const result: string[] = [];
|
||||
let current = '';
|
||||
let braceDepth = 0;
|
||||
|
||||
for (const ch of input) {
|
||||
if (ch === '{') {
|
||||
braceDepth++;
|
||||
}
|
||||
if (ch === '}') {
|
||||
braceDepth--;
|
||||
}
|
||||
if (ch === ',' && braceDepth === 0) {
|
||||
if (current.trim()) {
|
||||
result.push(current.trim());
|
||||
}
|
||||
current = '';
|
||||
} else {
|
||||
current += ch;
|
||||
}
|
||||
}
|
||||
if (current.trim()) {
|
||||
result.push(current.trim());
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
export const parseInputFiles = (files: string): string[] => {
|
||||
return files.split(/\r?\n/).reduce<string[]>(
|
||||
(acc, line) =>
|
||||
acc
|
||||
.concat(line.split(","))
|
||||
.filter((pat) => pat)
|
||||
.map((pat) => pat.trim()),
|
||||
[],
|
||||
);
|
||||
return files
|
||||
.split(/\r?\n/)
|
||||
.flatMap((line) => smartSplit(line))
|
||||
.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_ref: env.GITHUB_REF || "",
|
||||
github_repository: env.INPUT_REPOSITORY || env.GITHUB_REPOSITORY || "",
|
||||
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 || ""),
|
||||
input_draft: env.INPUT_DRAFT ? env.INPUT_DRAFT === "true" : undefined,
|
||||
input_prerelease: env.INPUT_PRERELEASE
|
||||
? env.INPUT_PRERELEASE == "true"
|
||||
input_files: parseInputFiles(env.INPUT_FILES || ''),
|
||||
input_working_directory: env.INPUT_WORKING_DIRECTORY || undefined,
|
||||
input_overwrite_files: env.INPUT_OVERWRITE_FILES
|
||||
? env.INPUT_OVERWRITE_FILES == 'true'
|
||||
: undefined,
|
||||
input_fail_on_unmatched_files: env.INPUT_FAIL_ON_UNMATCHED_FILES == "true",
|
||||
input_draft: env.INPUT_DRAFT ? env.INPUT_DRAFT === 'true' : undefined,
|
||||
input_preserve_order: env.INPUT_PRESERVE_ORDER ? env.INPUT_PRESERVE_ORDER == 'true' : undefined,
|
||||
input_prerelease: env.INPUT_PRERELEASE ? env.INPUT_PRERELEASE == 'true' : undefined,
|
||||
input_fail_on_unmatched_files: env.INPUT_FAIL_ON_UNMATCHED_FILES == 'true',
|
||||
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_append_body: env.INPUT_APPEND_BODY == "true",
|
||||
input_discussion_category_name: env.INPUT_DISCUSSION_CATEGORY_NAME || undefined,
|
||||
input_generate_release_notes: env.INPUT_GENERATE_RELEASE_NOTES == 'true',
|
||||
input_append_body: env.INPUT_APPEND_BODY == 'true',
|
||||
input_make_latest: parseMakeLatest(env.INPUT_MAKE_LATEST),
|
||||
};
|
||||
};
|
||||
|
||||
const parseMakeLatest = (
|
||||
value: string | undefined,
|
||||
): "true" | "false" | "legacy" | undefined => {
|
||||
if (value === "true" || value === "false" || value === "legacy") {
|
||||
const parseMakeLatest = (value: string | undefined): 'true' | 'false' | 'legacy' | undefined => {
|
||||
if (value === 'true' || value === 'false' || value === 'legacy') {
|
||||
return value;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const paths = (patterns: string[]): string[] => {
|
||||
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[] => {
|
||||
return acc.concat(
|
||||
glob.sync(pattern).filter((path) => statSync(path).isFile()),
|
||||
);
|
||||
const matches = glob.sync(normalizeFilePattern(pattern), { cwd, dot: true, absolute: false });
|
||||
const resolved = matches
|
||||
.map((p) => (cwd && !pathLib.isAbsolute(p) ? pathLib.join(cwd, p) : p))
|
||||
.filter((p) => {
|
||||
try {
|
||||
return statSync(p).isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
return acc.concat(resolved);
|
||||
}, []);
|
||||
};
|
||||
|
||||
export const unmatchedPatterns = (patterns: string[]): string[] => {
|
||||
export const unmatchedPatterns = (patterns: string[], cwd?: string): string[] => {
|
||||
return patterns.reduce((acc: string[], pattern: string): string[] => {
|
||||
return acc.concat(
|
||||
glob.sync(pattern).filter((path) => statSync(path).isFile()).length == 0
|
||||
? [pattern]
|
||||
: [],
|
||||
);
|
||||
const matches = glob.sync(normalizeFilePattern(pattern), { cwd, dot: true, absolute: false });
|
||||
const files = matches.filter((p) => {
|
||||
try {
|
||||
const full = cwd && !pathLib.isAbsolute(p) ? pathLib.join(cwd, p) : p;
|
||||
return statSync(full).isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
return acc.concat(files.length == 0 ? [pattern] : []);
|
||||
}, []);
|
||||
};
|
||||
|
||||
export const isTag = (ref: string): boolean => {
|
||||
return ref.startsWith("refs/tags/");
|
||||
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, '.');
|
||||
};
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
"useUnknownInCatchVariables": false,
|
||||
/* Basic Options */
|
||||
// "incremental": true, /* Enable incremental compilation */
|
||||
"target": "es6", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */
|
||||
"module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */
|
||||
"target": "es2022",
|
||||
"module": "NodeNext",
|
||||
// "allowJs": true, /* Allow javascript files to be compiled. */
|
||||
// "checkJs": true, /* Report errors in .js files. */
|
||||
// "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */
|
||||
@@ -25,6 +25,7 @@
|
||||
/* Strict Type-Checking Options */
|
||||
"strict": true, /* Enable all strict type-checking options. */
|
||||
"noImplicitAny": false, /* Raise error on expressions and declarations with an implied 'any' type. */
|
||||
"skipLibCheck": true,
|
||||
// "strictNullChecks": true, /* Enable strict null checks. */
|
||||
// "strictFunctionTypes": true, /* Enable strict checking of function types. */
|
||||
// "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */
|
||||
@@ -44,7 +45,7 @@
|
||||
// "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */
|
||||
// "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */
|
||||
// "typeRoots": [], /* List of folders to include type definitions from. */
|
||||
// "types": [], /* Type declaration files to be included in compilation. */
|
||||
"types": ["node", "vitest/globals"],
|
||||
// "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */
|
||||
"esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */
|
||||
// "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */
|
||||
@@ -60,5 +61,5 @@
|
||||
// "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */
|
||||
// "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */
|
||||
},
|
||||
"exclude": ["node_modules", "**/*.test.ts", "jest.config.ts"]
|
||||
"exclude": ["node_modules", "**/*.test.ts", "vitest.config.ts"]
|
||||
}
|
||||
|
||||
11
vitest.config.ts
Normal file
11
vitest.config.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'node',
|
||||
coverage: {
|
||||
reporter: ['text', 'lcov'],
|
||||
},
|
||||
include: ['__tests__/**/*.ts'],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user