├── .github ├── FUNDING.yml ├── dependabot.yml ├── release.yml └── workflows │ └── main.yml ├── .gitignore ├── .tool-versions ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── __tests__ ├── github.test.ts ├── release.txt └── util.test.ts ├── action.yml ├── demo.png ├── dist └── index.js ├── jest.config.ts ├── package-lock.json ├── package.json ├── src ├── github.ts ├── main.ts └── util.ts ├── tests └── data │ └── foo │ └── bar.txt └── tsconfig.json /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | ko_fi: softprops -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: npm 4 | directory: "/" 5 | schedule: 6 | interval: weekly 7 | groups: 8 | npm: 9 | patterns: 10 | - "*" 11 | ignore: 12 | - dependency-name: node-fetch 13 | versions: 14 | - ">=3.0.0" 15 | # ignore mime and @types/mime per https://github.com/softprops/action-gh-release/pull/475 16 | - dependency-name: mime 17 | versions: 18 | - ">=4.0.0" 19 | - dependency-name: "@types/mime" 20 | versions: 21 | - ">=4.0.0" 22 | commit-message: 23 | prefix: "chore(deps)" 24 | - package-ecosystem: github-actions 25 | directory: "/" 26 | schedule: 27 | interval: weekly 28 | groups: 29 | github-actions: 30 | patterns: 31 | - "*" 32 | commit-message: 33 | prefix: "chore(deps)" 34 | -------------------------------------------------------------------------------- /.github/release.yml: -------------------------------------------------------------------------------- 1 | changelog: 2 | exclude: 3 | labels: 4 | - ignore-for-release 5 | - github-actions 6 | authors: 7 | - octocat 8 | - renovate[bot] 9 | categories: 10 | - title: Breaking Changes 🛠 11 | labels: 12 | - breaking-change 13 | - title: Exciting New Features 🎉 14 | labels: 15 | - enhancement 16 | - feature 17 | - title: Bug fixes 🐛 18 | labels: 19 | - bug 20 | - title: Other Changes 🔄 21 | labels: 22 | - "*" 23 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: main 2 | 3 | on: 4 | push: 5 | pull_request: 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-24.04 10 | steps: 11 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 12 | 13 | - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 14 | with: 15 | node-version-file: ".tool-versions" 16 | cache: "npm" 17 | 18 | - name: Install 19 | run: npm ci 20 | - name: Build 21 | run: npm run build 22 | - name: Test 23 | run: npm run test 24 | - name: Format 25 | run: npm run fmtcheck 26 | # - name: "check for uncommitted changes" 27 | # # Ensure no changes, but ignore node_modules dir since dev/fresh ci deps installed. 28 | # run: | 29 | # git diff --exit-code --stat -- . ':!node_modules' \ 30 | # || (echo "##[error] found changed files after build. please 'npm run build && npm run fmt'" \ 31 | # "and check in all changes" \ 32 | # && exit 1) 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __tests__/runner/* 2 | # actions requires a node_modules dir https://github.com/actions/toolkit/blob/master/docs/javascript-action.md#publish-a-releasesv1-action 3 | # but its recommended not to check these in https://github.com/actions/toolkit/blob/master/docs/action-versioning.md#recommendations 4 | node_modules 5 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | nodejs 20.19.0 2 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 2.2.2 2 | 3 | ## What's Changed 4 | 5 | ### Bug fixes 🐛 6 | 7 | * fix: updating release draft status from true to false by @galargh in https://github.com/softprops/action-gh-release/pull/316 8 | 9 | ### Other Changes 🔄 10 | 11 | * chore: simplify ref_type test by @steinybot in https://github.com/softprops/action-gh-release/pull/598 12 | * fix(docs): clarify the default for tag_name by @muzimuzhi in https://github.com/softprops/action-gh-release/pull/599 13 | * test(release): add unit tests when searching for a release by @rwaskiewicz in https://github.com/softprops/action-gh-release/pull/603 14 | * dependency updates 15 | 16 | ## 2.2.1 17 | 18 | ## What's Changed 19 | 20 | ### Bug fixes 🐛 21 | 22 | * fix: big file uploads by @xen0n in https://github.com/softprops/action-gh-release/pull/562 23 | 24 | ### Other Changes 🔄 25 | * 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 26 | * 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 27 | * 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 28 | 29 | ## 2.2.0 30 | 31 | ## What's Changed 32 | 33 | ### Exciting New Features 🎉 34 | 35 | * feat: read the release assets asynchronously by @xen0n in https://github.com/softprops/action-gh-release/pull/552 36 | 37 | ### Bug fixes 🐛 38 | 39 | * fix(docs): clarify the default for tag_name by @alexeagle in https://github.com/softprops/action-gh-release/pull/544 40 | 41 | ### Other Changes 🔄 42 | 43 | * chore(deps): bump typescript from 5.6.3 to 5.7.2 by @dependabot in https://github.com/softprops/action-gh-release/pull/548 44 | * 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 45 | * 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 46 | * 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 47 | * chore(deps): bump prettier from 3.3.3 to 3.4.1 by @dependabot in https://github.com/softprops/action-gh-release/pull/550 48 | * 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 49 | * chore(deps): bump prettier from 3.4.1 to 3.4.2 by @dependabot in https://github.com/softprops/action-gh-release/pull/554 50 | 51 | ## 2.1.0 52 | 53 | ## What's Changed 54 | 55 | ### Exciting New Features 🎉 56 | * feat: add support for release assets with multiple spaces within the name by @dukhine in https://github.com/softprops/action-gh-release/pull/518 57 | * feat: preserve upload order by @richarddd in https://github.com/softprops/action-gh-release/pull/500 58 | 59 | ### Other Changes 🔄 60 | * 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 61 | 62 | ## 2.0.9 63 | 64 | - maintenance release with updated dependencies 65 | 66 | ## 2.0.8 67 | 68 | ### Other Changes 🔄 69 | * chore(deps): bump prettier from 2.8.0 to 3.3.3 by @dependabot in https://github.com/softprops/action-gh-release/pull/480 70 | * chore(deps): bump @types/node from 20.14.9 to 20.14.11 by @dependabot in https://github.com/softprops/action-gh-release/pull/483 71 | * chore(deps): bump @octokit/plugin-throttling from 9.3.0 to 9.3.1 by @dependabot in https://github.com/softprops/action-gh-release/pull/484 72 | * chore(deps): bump glob from 10.4.2 to 11.0.0 by @dependabot in https://github.com/softprops/action-gh-release/pull/477 73 | * refactor: write jest config in ts by @chenrui333 in https://github.com/softprops/action-gh-release/pull/485 74 | * chore(deps): bump @actions/github from 5.1.1 to 6.0.0 by @dependabot in https://github.com/softprops/action-gh-release/pull/470 75 | 76 | ## 2.0.7 77 | 78 | ### Bug fixes 🐛 79 | 80 | * Fix missing update release body by @FirelightFlagboy in https://github.com/softprops/action-gh-release/pull/365 81 | 82 | ### Other Changes 🔄 83 | 84 | * Bump @octokit/plugin-retry from 4.0.3 to 7.1.1 by @dependabot in https://github.com/softprops/action-gh-release/pull/443 85 | * Bump typescript from 4.9.5 to 5.5.2 by @dependabot in https://github.com/softprops/action-gh-release/pull/467 86 | * Bump @types/node from 20.14.6 to 20.14.8 by @dependabot in https://github.com/softprops/action-gh-release/pull/469 87 | * Bump @types/node from 20.14.8 to 20.14.9 by @dependabot in https://github.com/softprops/action-gh-release/pull/473 88 | * Bump typescript from 5.5.2 to 5.5.3 by @dependabot in https://github.com/softprops/action-gh-release/pull/472 89 | * Bump ts-jest from 29.1.5 to 29.2.2 by @dependabot in https://github.com/softprops/action-gh-release/pull/479 90 | * docs: document that existing releases are updated by @jvanbruegge in https://github.com/softprops/action-gh-release/pull/474 91 | 92 | ## 2.0.6 93 | 94 | - maintenance release with updated dependencies 95 | 96 | ## 2.0.5 97 | 98 | - Factor in file names with spaces when upserting files [#446](https://github.com/softprops/action-gh-release/pull/446) via [@MystiPanda](https://github.com/MystiPanda) 99 | - Improvements to error handling [#449](https://github.com/softprops/action-gh-release/pull/449) via [@till](https://github.com/till) 100 | 101 | ## 2.0.4 102 | 103 | - Minor follow up to [#417](https://github.com/softprops/action-gh-release/pull/417). [#425](https://github.com/softprops/action-gh-release/pull/425) 104 | 105 | ## 2.0.3 106 | 107 | - Declare `make_latest` as an input field in `action.yml` [#419](https://github.com/softprops/action-gh-release/pull/419) 108 | 109 | ## 2.0.2 110 | 111 | - Revisit approach to [#384](https://github.com/softprops/action-gh-release/pull/384) making unresolved pattern failures opt-in [#417](https://github.com/softprops/action-gh-release/pull/417) 112 | 113 | ## 2.0.1 114 | 115 | - Add support for make_latest property [#304](https://github.com/softprops/action-gh-release/pull/304) via [@samueljseay](https://github.com/samueljseay) 116 | - Fail run if files setting contains invalid patterns [#384](https://github.com/softprops/action-gh-release/pull/384) via [@rpdelaney](https://github.com/rpdelaney) 117 | - Add support for proxy env variables (don't use node-fetch) [#386](https://github.com/softprops/action-gh-release/pull/386/) via [@timor-raiman](https://github.com/timor-raiman) 118 | - Suppress confusing warning when input_files is empty [#389](https://github.com/softprops/action-gh-release/pull/389) via [@Drowze](https://github.com/Drowze) 119 | 120 | ## 2.0.0 121 | 122 | - `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. 123 | - Upgrade action.yml declaration to node20 to address deprecations 124 | 125 | ## 0.1.15 126 | 127 | - Upgrade to action.yml declaration to node16 to address deprecations 128 | - Upgrade dependencies 129 | - Add `asset` output as a JSON array containing information about the uploaded assets 130 | 131 | ## 0.1.14 132 | 133 | - 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 134 | 135 | ## 0.1.13 136 | 137 | - fix issue with multiple runs concatenating release bodies [#145](https://github.com/softprops/action-gh-release/pull/145) 138 | 139 | ## 0.1.12 140 | 141 | - 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) 142 | 143 | ## 0.1.11 144 | 145 | - better error message on release create failed [#143](https://github.com/softprops/action-gh-release/pull/143) 146 | 147 | ## 0.1.10 148 | 149 | - fixed error message formatting for file uploads 150 | 151 | ## 0.1.9 152 | 153 | - add support for linking release to GitHub discussion [#136](https://github.com/softprops/action-gh-release/pull/136) 154 | 155 | ## 0.1.8 156 | 157 | - 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) 158 | - 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) 159 | - trim white space in provided `tag_name` [#130](https://github.com/softprops/action-gh-release/pull/130) 160 | 161 | ## 0.1.7 162 | 163 | - allow creating draft releases without a tag [#95](https://github.com/softprops/action-gh-release/pull/95) 164 | - Set default token for simpler setup [#83](https://github.com/softprops/action-gh-release/pull/83) 165 | - fix regression with action yml [#126](https://github.com/softprops/action-gh-release/pull/126) 166 | 167 | ## 0.1.6 168 | 169 | This is a release catch up have a hiatus. Future releases will happen more frequently 170 | 171 | - 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) 172 | - Add `repository` input, useful for creating a release in an external repository [#61](https://github.com/softprops/action-gh-release/pull/61) 173 | - 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) 174 | - Add `upload_url` as action output, useful for managing uploads separately [#75](https://github.com/softprops/action-gh-release/pull/75) 175 | - Support custom `target_commitish` value, useful to customize the default [#76](https://github.com/softprops/action-gh-release/pull/76) 176 | - 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) 177 | - 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) 178 | - 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) 179 | 180 | ## 0.1.5 181 | 182 | - Added support for specifying tag name [#39](https://github.com/softprops/action-gh-release/pull/39) 183 | 184 | ## 0.1.4 185 | 186 | - Added support for updating releases body [#36](https://github.com/softprops/action-gh-release/pull/36) 187 | - 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) 188 | - Added basic GitHub API retry support to manage API turbulance [#26](https://github.com/softprops/action-gh-release/pull/26) 189 | 190 | ## 0.1.3 191 | 192 | - Fixed where `with: body_path` was not being used in generated GitHub releases 193 | 194 | ## 0.1.2 195 | 196 | - Add support for merging draft releases [#16](https://github.com/softprops/action-gh-release/pull/16) 197 | 198 | GitHub's api doesn't explicitly have a way of fetching a draft release by tag name which caused draft releases to appear as separate releases when used in a build matrix. 199 | This is now fixed. 200 | 201 | - Add support for newline-delimited asset list [#18](https://github.com/softprops/action-gh-release/pull/18) 202 | 203 | 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 204 | 205 | ```yaml 206 | - name: Release 207 | uses: softprops/action-gh-release@v1 208 | if: startsWith(github.ref, 'refs/tags/') 209 | with: 210 | files: | 211 | filea.txt 212 | fileb.txt 213 | filec.txt 214 | env: 215 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 216 | ``` 217 | 218 | - Add support for prerelease annotated GitHub releases with the new input field `with.prerelease: true` [#19](https://github.com/softprops/action-gh-release/pull/19) 219 | 220 | --- 221 | 222 | ## 0.1.1 223 | 224 | - Add support for publishing releases on all supported virtual hosts 225 | 226 | You'll need to remove `docker://` prefix and use the `@v1` action tag 227 | 228 | --- 229 | 230 | ## 0.1.0 231 | 232 | - Initial release 233 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## bootstrapping 2 | 3 | This a [JavaScript](https://help.github.com/en/articles/about-actions#types-of-actions) action but uses [TypeScript](https://www.typescriptlang.org/docs/home.html) to generate that JavaScript. 4 | 5 | You can bootstrap your environment with a modern version of npm and by running `npm i` at the root of this repo. 6 | 7 | ## testing 8 | 9 | Tests can be found under under `__tests__` directory and are runnable with the `npm t` command. 10 | 11 | ## source code 12 | 13 | Source code can be found under the `src` directory. Running `npm run build` will generate the JavaScript that will run within GitHub workflows. 14 | 15 | ## formatting 16 | 17 | A minimal attempt at keeping a consistent code style is can be applied by running `npm run fmt`. 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2019-current Doug Tangren 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 📦 :octocat: 3 |
4 |

5 | action gh-release 6 |

7 | 8 |

9 | A GitHub Action for creating GitHub Releases on Linux, Windows, and macOS virtual environments 10 |

11 | 12 |
13 | 14 |
15 | 16 |
17 | 18 | 19 | 20 |
21 | 22 |
23 | 24 | - [🤸 Usage](#-usage) 25 | - [🚥 Limit releases to pushes to tags](#-limit-releases-to-pushes-to-tags) 26 | - [⬆️ Uploading release assets](#️-uploading-release-assets) 27 | - [📝 External release notes](#-external-release-notes) 28 | - [💅 Customizing](#-customizing) 29 | - [inputs](#inputs) 30 | - [outputs](#outputs) 31 | - [environment variables](#environment-variables) 32 | - [Permissions](#permissions) 33 | 34 | ## 🤸 Usage 35 | 36 | ### 🚥 Limit releases to pushes to tags 37 | 38 | Typically usage of this action involves adding a step to a build that 39 | is gated pushes to git tags. You may find `step.if` field helpful in accomplishing this 40 | as it maximizes the reuse value of your workflow for non-tag pushes. 41 | 42 | Below is a simple example of `step.if` tag gating 43 | 44 | ```yaml 45 | name: Main 46 | 47 | on: push 48 | 49 | jobs: 50 | build: 51 | runs-on: ubuntu-latest 52 | steps: 53 | - name: Checkout 54 | uses: actions/checkout@v4 55 | - name: Release 56 | uses: softprops/action-gh-release@v2 57 | if: github.ref_type == 'tag' 58 | ``` 59 | 60 | You can also use push config tag filter 61 | 62 | ```yaml 63 | name: Main 64 | 65 | on: 66 | push: 67 | tags: 68 | - "v*.*.*" 69 | 70 | jobs: 71 | build: 72 | runs-on: ubuntu-latest 73 | steps: 74 | - name: Checkout 75 | uses: actions/checkout@v4 76 | - name: Release 77 | uses: softprops/action-gh-release@v2 78 | ``` 79 | 80 | ### ⬆️ Uploading release assets 81 | 82 | You can configure a number of options for your 83 | GitHub release and all are optional. 84 | 85 | A common case for GitHub releases is to upload your binary after its been validated and packaged. 86 | Use the `with.files` input to declare a newline-delimited list of glob expressions matching the files 87 | you wish to upload to GitHub releases. If you'd like you can just list the files by name directly. 88 | If a tag already has a GitHub release, the existing release will be updated with the release assets. 89 | 90 | Below is an example of uploading a single asset named `Release.txt` 91 | 92 | ```yaml 93 | name: Main 94 | 95 | on: push 96 | 97 | jobs: 98 | build: 99 | runs-on: ubuntu-latest 100 | steps: 101 | - name: Checkout 102 | uses: actions/checkout@v4 103 | - name: Build 104 | run: echo ${{ github.sha }} > Release.txt 105 | - name: Test 106 | run: cat Release.txt 107 | - name: Release 108 | uses: softprops/action-gh-release@v2 109 | if: github.ref_type == 'tag' 110 | with: 111 | files: Release.txt 112 | ``` 113 | 114 | Below is an example of uploading more than one asset with a GitHub release 115 | 116 | ```yaml 117 | name: Main 118 | 119 | on: push 120 | 121 | jobs: 122 | build: 123 | runs-on: ubuntu-latest 124 | steps: 125 | - name: Checkout 126 | uses: actions/checkout@v4 127 | - name: Build 128 | run: echo ${{ github.sha }} > Release.txt 129 | - name: Test 130 | run: cat Release.txt 131 | - name: Release 132 | uses: softprops/action-gh-release@v2 133 | if: github.ref_type == 'tag' 134 | with: 135 | files: | 136 | Release.txt 137 | LICENSE 138 | ``` 139 | 140 | > **⚠️ 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) 141 | 142 | > **⚠️ 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 '\\','/'` 143 | 144 | ### 📝 External release notes 145 | 146 | Many systems exist that can help generate release notes for you. This action supports 147 | loading release notes from a path in your repository's build to allow for the flexibility 148 | of using any changelog generator for your releases, including a human 👩‍💻 149 | 150 | ```yaml 151 | name: Main 152 | 153 | on: push 154 | 155 | jobs: 156 | build: 157 | runs-on: ubuntu-latest 158 | steps: 159 | - name: Checkout 160 | uses: actions/checkout@v4 161 | - name: Generate Changelog 162 | run: echo "# Good things have arrived" > ${{ github.workspace }}-CHANGELOG.txt 163 | - name: Release 164 | uses: softprops/action-gh-release@v2 165 | if: github.ref_type == 'tag' 166 | with: 167 | body_path: ${{ github.workspace }}-CHANGELOG.txt 168 | repository: my_gh_org/my_gh_repo 169 | # note you'll typically need to create a personal access token 170 | # with permissions to create releases in the other repo 171 | token: ${{ secrets.CUSTOM_GITHUB_TOKEN }} 172 | ``` 173 | 174 | ### 💅 Customizing 175 | 176 | #### inputs 177 | 178 | The following are optional as `step.with` keys 179 | 180 | | Name | Type | Description | 181 | | -------------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 182 | | `body` | String | Text communicating notable changes in this release | 183 | | `body_path` | String | Path to load text communicating notable changes in this release | 184 | | `draft` | Boolean | Indicator of whether or not this release is a draft | 185 | | `prerelease` | Boolean | Indicator of whether or not is a prerelease | 186 | | `preserve_order` | Boolean | Indicator of whether order of files should be preserved when uploading assets | 187 | | `files` | String | Newline-delimited globs of paths to assets to upload for release | 188 | | `name` | String | Name of the release. defaults to tag name | 189 | | `tag_name` | String | Name of a tag. defaults to `github.ref_name` | 190 | | `fail_on_unmatched_files` | Boolean | Indicator of whether to fail if any of the `files` globs match nothing | 191 | | `repository` | String | Name of a target repository in `/` format. Defaults to GITHUB_REPOSITORY env variable | 192 | | `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. | 193 | | `token` | String | Secret GitHub Personal Access Token. Defaults to `${{ github.token }}` | 194 | | `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) | 195 | | `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 | 196 | | `append_body` | Boolean | Append to existing body instead of overwriting it | 197 | | `make_latest` | String | Specifies whether this release should be set as the latest release for the repository. Drafts and prereleases cannot be set as latest. Can be `true`, `false`, or `legacy`. Uses GitHub api defaults if not provided | 198 | 199 | 💡 When providing a `body` and `body_path` at the same time, `body_path` will be 200 | attempted first, then falling back on `body` if the path can not be read from. 201 | 202 | 💡 When the release info keys (such as `name`, `body`, `draft`, `prerelease`, etc.) 203 | are not explicitly set and there is already an existing release for the tag, the 204 | release will retain its original info. 205 | 206 | #### outputs 207 | 208 | The following outputs can be accessed via `${{ steps..outputs }}` from this action 209 | 210 | | Name | Type | Description | 211 | | ------------ | ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 212 | | `url` | String | Github.com URL for the release | 213 | | `id` | String | Release ID | 214 | | `upload_url` | String | URL for uploading assets to the release | 215 | | `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) | 216 | 217 | As an example, you can use `${{ fromJSON(steps..outputs.assets)[0].browser_download_url }}` to get the download URL of the first asset. 218 | 219 | #### environment variables 220 | 221 | The following `step.env` keys are allowed as a fallback but deprecated in favor of using inputs. 222 | 223 | | Name | Description | 224 | | ------------------- | ------------------------------------------------------------------------------------------ | 225 | | `GITHUB_TOKEN` | GITHUB_TOKEN as provided by `secrets` | 226 | | `GITHUB_REPOSITORY` | Name of a target repository in `/` format. defaults to the current repository | 227 | 228 | > **⚠️ Note:** This action was previously implemented as a Docker container, limiting its use to GitHub Actions Linux virtual environments only. With recent releases, we now support cross platform usage. You'll need to remove the `docker://` prefix in these versions 229 | 230 | ### Permissions 231 | 232 | This Action requires the following permissions on the GitHub integration token: 233 | 234 | ```yaml 235 | permissions: 236 | contents: write 237 | ``` 238 | 239 | When used with `discussion_category_name`, additional permission is needed: 240 | 241 | ```yaml 242 | permissions: 243 | contents: write 244 | discussions: write 245 | ``` 246 | 247 | [GitHub token permissions](https://docs.github.com/en/actions/security-guides/automatic-token-authentication#permissions-for-the-github_token) can be set for an individual job, workflow, or for Actions as a whole. 248 | 249 | Note that if you intend to run workflows on the release event (`on: { release: { types: [published] } }`), you need to use 250 | a personal access token for this action, as the [default `secrets.GITHUB_TOKEN` does not trigger another workflow](https://github.com/actions/create-release/issues/71). 251 | 252 | Doug Tangren (softprops) 2019 253 | -------------------------------------------------------------------------------- /__tests__/github.test.ts: -------------------------------------------------------------------------------- 1 | import * as assert from "assert"; 2 | import { 3 | mimeOrDefault, 4 | asset, 5 | Releaser, 6 | Release, 7 | findTagFromReleases, 8 | } from "../src/github"; 9 | 10 | describe("github", () => { 11 | describe("mimeOrDefault", () => { 12 | it("returns a specific mime for common path", async () => { 13 | assert.equal(mimeOrDefault("foo.tar.gz"), "application/gzip"); 14 | }); 15 | it("returns default mime for uncommon path", async () => { 16 | assert.equal(mimeOrDefault("foo.uncommon"), "application/octet-stream"); 17 | }); 18 | }); 19 | 20 | describe("asset", () => { 21 | it("derives asset info from a path", async () => { 22 | const { name, mime, size } = asset("tests/data/foo/bar.txt"); 23 | assert.equal(name, "bar.txt"); 24 | assert.equal(mime, "text/plain"); 25 | assert.equal(size, 10); 26 | }); 27 | }); 28 | 29 | describe("findTagFromReleases", () => { 30 | const owner = "owner"; 31 | const repo = "repo"; 32 | 33 | const mockRelease: Release = { 34 | id: 1, 35 | upload_url: `https://api.github.com/repos/${owner}/${repo}/releases/1/assets`, 36 | html_url: `https://github.com/${owner}/${repo}/releases/tag/v1.0.0`, 37 | tag_name: "v1.0.0", 38 | name: "Test Release", 39 | body: "Test body", 40 | target_commitish: "main", 41 | draft: false, 42 | prerelease: false, 43 | assets: [], 44 | } as const; 45 | 46 | const mockReleaser: Releaser = { 47 | getReleaseByTag: () => Promise.reject("Not implemented"), 48 | createRelease: () => Promise.reject("Not implemented"), 49 | updateRelease: () => Promise.reject("Not implemented"), 50 | allReleases: async function* () { 51 | yield { data: [mockRelease] }; 52 | }, 53 | } as const; 54 | 55 | describe("when the tag_name is not an empty string", () => { 56 | const targetTag = "v1.0.0"; 57 | 58 | it("finds a matching release in first batch of results", async () => { 59 | const targetRelease = { 60 | ...mockRelease, 61 | owner, 62 | repo, 63 | tag_name: targetTag, 64 | }; 65 | const otherRelease = { 66 | ...mockRelease, 67 | owner, 68 | repo, 69 | tag_name: "v1.0.1", 70 | }; 71 | 72 | const releaser = { 73 | ...mockReleaser, 74 | allReleases: async function* () { 75 | yield { data: [targetRelease] }; 76 | yield { data: [otherRelease] }; 77 | }, 78 | }; 79 | 80 | const result = await findTagFromReleases( 81 | releaser, 82 | owner, 83 | repo, 84 | targetTag, 85 | ); 86 | 87 | assert.deepStrictEqual(result, targetRelease); 88 | }); 89 | 90 | it("finds a matching release in second batch of results", async () => { 91 | const targetRelease = { 92 | ...mockRelease, 93 | owner, 94 | repo, 95 | tag_name: targetTag, 96 | }; 97 | const otherRelease = { 98 | ...mockRelease, 99 | owner, 100 | repo, 101 | tag_name: "v1.0.1", 102 | }; 103 | 104 | const releaser = { 105 | ...mockReleaser, 106 | allReleases: async function* () { 107 | yield { data: [otherRelease] }; 108 | yield { data: [targetRelease] }; 109 | }, 110 | }; 111 | 112 | const result = await findTagFromReleases( 113 | releaser, 114 | owner, 115 | repo, 116 | targetTag, 117 | ); 118 | assert.deepStrictEqual(result, targetRelease); 119 | }); 120 | 121 | it("returns undefined when a release is not found in any batch", async () => { 122 | const otherRelease = { 123 | ...mockRelease, 124 | owner, 125 | repo, 126 | tag_name: "v1.0.1", 127 | }; 128 | const releaser = { 129 | ...mockReleaser, 130 | allReleases: async function* () { 131 | yield { data: [otherRelease] }; 132 | yield { data: [otherRelease] }; 133 | }, 134 | }; 135 | 136 | const result = await findTagFromReleases( 137 | releaser, 138 | owner, 139 | repo, 140 | targetTag, 141 | ); 142 | 143 | assert.strictEqual(result, undefined); 144 | }); 145 | 146 | it("returns undefined when no releases are returned", async () => { 147 | const releaser = { 148 | ...mockReleaser, 149 | allReleases: async function* () { 150 | yield { data: [] }; 151 | }, 152 | }; 153 | 154 | const result = await findTagFromReleases( 155 | releaser, 156 | owner, 157 | repo, 158 | targetTag, 159 | ); 160 | 161 | assert.strictEqual(result, undefined); 162 | }); 163 | }); 164 | 165 | describe("when the tag_name is an empty string", () => { 166 | const emptyTag = ""; 167 | 168 | it("finds a matching release in first batch of results", async () => { 169 | const targetRelease = { 170 | ...mockRelease, 171 | owner, 172 | repo, 173 | tag_name: emptyTag, 174 | }; 175 | const otherRelease = { 176 | ...mockRelease, 177 | owner, 178 | repo, 179 | tag_name: "v1.0.1", 180 | }; 181 | 182 | const releaser = { 183 | ...mockReleaser, 184 | allReleases: async function* () { 185 | yield { data: [targetRelease] }; 186 | yield { data: [otherRelease] }; 187 | }, 188 | }; 189 | 190 | const result = await findTagFromReleases( 191 | releaser, 192 | owner, 193 | repo, 194 | emptyTag, 195 | ); 196 | 197 | assert.deepStrictEqual(result, targetRelease); 198 | }); 199 | 200 | it("finds a matching release in second batch of results", async () => { 201 | const targetRelease = { 202 | ...mockRelease, 203 | owner, 204 | repo, 205 | tag_name: emptyTag, 206 | }; 207 | const otherRelease = { 208 | ...mockRelease, 209 | owner, 210 | repo, 211 | tag_name: "v1.0.1", 212 | }; 213 | 214 | const releaser = { 215 | ...mockReleaser, 216 | allReleases: async function* () { 217 | yield { data: [otherRelease] }; 218 | yield { data: [targetRelease] }; 219 | }, 220 | }; 221 | 222 | const result = await findTagFromReleases( 223 | releaser, 224 | owner, 225 | repo, 226 | emptyTag, 227 | ); 228 | assert.deepStrictEqual(result, targetRelease); 229 | }); 230 | 231 | it("returns undefined when a release is not found in any batch", async () => { 232 | const otherRelease = { 233 | ...mockRelease, 234 | owner, 235 | repo, 236 | tag_name: "v1.0.1", 237 | }; 238 | const releaser = { 239 | ...mockReleaser, 240 | allReleases: async function* () { 241 | yield { data: [otherRelease] }; 242 | yield { data: [otherRelease] }; 243 | }, 244 | }; 245 | 246 | const result = await findTagFromReleases( 247 | releaser, 248 | owner, 249 | repo, 250 | emptyTag, 251 | ); 252 | 253 | assert.strictEqual(result, undefined); 254 | }); 255 | 256 | it("returns undefined when no releases are returned", async () => { 257 | const releaser = { 258 | ...mockReleaser, 259 | allReleases: async function* () { 260 | yield { data: [] }; 261 | }, 262 | }; 263 | 264 | const result = await findTagFromReleases( 265 | releaser, 266 | owner, 267 | repo, 268 | emptyTag, 269 | ); 270 | 271 | assert.strictEqual(result, undefined); 272 | }); 273 | }); 274 | }); 275 | }); 276 | -------------------------------------------------------------------------------- /__tests__/release.txt: -------------------------------------------------------------------------------- 1 | bar -------------------------------------------------------------------------------- /__tests__/util.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | releaseBody, 3 | isTag, 4 | paths, 5 | parseConfig, 6 | parseInputFiles, 7 | unmatchedPatterns, 8 | uploadUrl, 9 | alignAssetName, 10 | } from "../src/util"; 11 | import * as assert from "assert"; 12 | 13 | describe("util", () => { 14 | describe("uploadUrl", () => { 15 | it("strips template", () => { 16 | assert.equal( 17 | uploadUrl( 18 | "https://uploads.github.com/repos/octocat/Hello-World/releases/1/assets{?name,label}", 19 | ), 20 | "https://uploads.github.com/repos/octocat/Hello-World/releases/1/assets", 21 | ); 22 | }); 23 | }); 24 | describe("parseInputFiles", () => { 25 | it("parses empty strings", () => { 26 | assert.deepStrictEqual(parseInputFiles(""), []); 27 | }); 28 | it("parses comma-delimited strings", () => { 29 | assert.deepStrictEqual(parseInputFiles("foo,bar"), ["foo", "bar"]); 30 | }); 31 | it("parses newline and comma-delimited (and then some)", () => { 32 | assert.deepStrictEqual( 33 | parseInputFiles("foo,bar\nbaz,boom,\n\ndoom,loom "), 34 | ["foo", "bar", "baz", "boom", "doom", "loom"], 35 | ); 36 | }); 37 | }); 38 | describe("releaseBody", () => { 39 | it("uses input body", () => { 40 | assert.equal( 41 | "foo", 42 | releaseBody({ 43 | github_ref: "", 44 | github_repository: "", 45 | github_token: "", 46 | input_body: "foo", 47 | input_body_path: undefined, 48 | input_draft: false, 49 | input_prerelease: false, 50 | input_preserve_order: undefined, 51 | input_files: [], 52 | input_name: undefined, 53 | input_tag_name: undefined, 54 | input_target_commitish: undefined, 55 | input_discussion_category_name: undefined, 56 | input_generate_release_notes: false, 57 | input_make_latest: undefined, 58 | }), 59 | ); 60 | }); 61 | it("uses input body path", () => { 62 | assert.equal( 63 | "bar", 64 | releaseBody({ 65 | github_ref: "", 66 | github_repository: "", 67 | github_token: "", 68 | input_body: undefined, 69 | input_body_path: "__tests__/release.txt", 70 | input_draft: false, 71 | input_prerelease: false, 72 | input_preserve_order: undefined, 73 | input_files: [], 74 | input_name: undefined, 75 | input_tag_name: undefined, 76 | input_target_commitish: undefined, 77 | input_discussion_category_name: undefined, 78 | input_generate_release_notes: false, 79 | input_make_latest: undefined, 80 | }), 81 | ); 82 | }); 83 | it("defaults to body path when both body and body path are provided", () => { 84 | assert.equal( 85 | "bar", 86 | releaseBody({ 87 | github_ref: "", 88 | github_repository: "", 89 | github_token: "", 90 | input_body: "foo", 91 | input_body_path: "__tests__/release.txt", 92 | input_draft: false, 93 | input_prerelease: false, 94 | input_preserve_order: undefined, 95 | input_files: [], 96 | input_name: undefined, 97 | input_tag_name: undefined, 98 | input_target_commitish: undefined, 99 | input_discussion_category_name: undefined, 100 | input_generate_release_notes: false, 101 | input_make_latest: undefined, 102 | }), 103 | ); 104 | }); 105 | }); 106 | describe("parseConfig", () => { 107 | it("parses basic config", () => { 108 | assert.deepStrictEqual( 109 | parseConfig({ 110 | // note: inputs declared in actions.yml, even when declared not required, 111 | // are still provided by the actions runtime env as empty strings instead of 112 | // the normal absent env value one would expect. this breaks things 113 | // as an empty string !== undefined in terms of what we pass to the api 114 | // so we cover that in a test case here to ensure undefined values are actually 115 | // resolved as undefined and not empty strings 116 | INPUT_TARGET_COMMITISH: "", 117 | INPUT_DISCUSSION_CATEGORY_NAME: "", 118 | }), 119 | { 120 | github_ref: "", 121 | github_repository: "", 122 | github_token: "", 123 | input_append_body: false, 124 | input_body: undefined, 125 | input_body_path: undefined, 126 | input_draft: undefined, 127 | input_prerelease: undefined, 128 | input_preserve_order: undefined, 129 | input_files: [], 130 | input_name: undefined, 131 | input_tag_name: undefined, 132 | input_fail_on_unmatched_files: false, 133 | input_target_commitish: undefined, 134 | input_discussion_category_name: undefined, 135 | input_generate_release_notes: false, 136 | input_make_latest: undefined, 137 | }, 138 | ); 139 | }); 140 | 141 | it("parses basic config with commitish", () => { 142 | assert.deepStrictEqual( 143 | parseConfig({ 144 | INPUT_TARGET_COMMITISH: "affa18ef97bc9db20076945705aba8c516139abd", 145 | }), 146 | { 147 | github_ref: "", 148 | github_repository: "", 149 | github_token: "", 150 | input_append_body: false, 151 | input_body: undefined, 152 | input_body_path: undefined, 153 | input_draft: undefined, 154 | input_prerelease: undefined, 155 | input_files: [], 156 | input_preserve_order: undefined, 157 | input_name: undefined, 158 | input_tag_name: undefined, 159 | input_fail_on_unmatched_files: false, 160 | input_target_commitish: "affa18ef97bc9db20076945705aba8c516139abd", 161 | input_discussion_category_name: undefined, 162 | input_generate_release_notes: false, 163 | input_make_latest: undefined, 164 | }, 165 | ); 166 | }); 167 | it("supports discussion category names", () => { 168 | assert.deepStrictEqual( 169 | parseConfig({ 170 | INPUT_DISCUSSION_CATEGORY_NAME: "releases", 171 | }), 172 | { 173 | github_ref: "", 174 | github_repository: "", 175 | github_token: "", 176 | input_append_body: false, 177 | input_body: undefined, 178 | input_body_path: undefined, 179 | input_draft: undefined, 180 | input_prerelease: undefined, 181 | input_files: [], 182 | input_preserve_order: undefined, 183 | input_name: undefined, 184 | input_tag_name: undefined, 185 | input_fail_on_unmatched_files: false, 186 | input_target_commitish: undefined, 187 | input_discussion_category_name: "releases", 188 | input_generate_release_notes: false, 189 | input_make_latest: undefined, 190 | }, 191 | ); 192 | }); 193 | 194 | it("supports generating release notes", () => { 195 | assert.deepStrictEqual( 196 | parseConfig({ 197 | INPUT_GENERATE_RELEASE_NOTES: "true", 198 | }), 199 | { 200 | github_ref: "", 201 | github_repository: "", 202 | github_token: "", 203 | input_append_body: false, 204 | input_body: undefined, 205 | input_body_path: undefined, 206 | input_draft: undefined, 207 | input_prerelease: undefined, 208 | input_preserve_order: undefined, 209 | input_files: [], 210 | input_name: undefined, 211 | input_tag_name: undefined, 212 | input_fail_on_unmatched_files: false, 213 | input_target_commitish: undefined, 214 | input_discussion_category_name: undefined, 215 | input_generate_release_notes: true, 216 | input_make_latest: undefined, 217 | }, 218 | ); 219 | }); 220 | 221 | it("prefers GITHUB_TOKEN over token input for backwards compatibility", () => { 222 | assert.deepStrictEqual( 223 | parseConfig({ 224 | INPUT_DRAFT: "false", 225 | INPUT_PRERELEASE: "true", 226 | INPUT_PRESERVE_ORDER: "true", 227 | GITHUB_TOKEN: "env-token", 228 | INPUT_TOKEN: "input-token", 229 | }), 230 | { 231 | github_ref: "", 232 | github_repository: "", 233 | github_token: "env-token", 234 | input_append_body: false, 235 | input_body: undefined, 236 | input_body_path: undefined, 237 | input_draft: false, 238 | input_prerelease: true, 239 | input_preserve_order: true, 240 | input_files: [], 241 | input_name: undefined, 242 | input_tag_name: undefined, 243 | input_fail_on_unmatched_files: false, 244 | input_target_commitish: undefined, 245 | input_discussion_category_name: undefined, 246 | input_generate_release_notes: false, 247 | input_make_latest: undefined, 248 | }, 249 | ); 250 | }); 251 | it("uses input token as the source of GITHUB_TOKEN by default", () => { 252 | assert.deepStrictEqual( 253 | parseConfig({ 254 | INPUT_DRAFT: "false", 255 | INPUT_PRERELEASE: "true", 256 | INPUT_TOKEN: "input-token", 257 | }), 258 | { 259 | github_ref: "", 260 | github_repository: "", 261 | github_token: "input-token", 262 | input_append_body: false, 263 | input_body: undefined, 264 | input_body_path: undefined, 265 | input_draft: false, 266 | input_prerelease: true, 267 | input_preserve_order: undefined, 268 | input_files: [], 269 | input_name: undefined, 270 | input_tag_name: undefined, 271 | input_fail_on_unmatched_files: false, 272 | input_target_commitish: undefined, 273 | input_discussion_category_name: undefined, 274 | input_generate_release_notes: false, 275 | input_make_latest: undefined, 276 | }, 277 | ); 278 | }); 279 | it("parses basic config with draft and prerelease", () => { 280 | assert.deepStrictEqual( 281 | parseConfig({ 282 | INPUT_DRAFT: "false", 283 | INPUT_PRERELEASE: "true", 284 | }), 285 | { 286 | github_ref: "", 287 | github_repository: "", 288 | github_token: "", 289 | input_append_body: false, 290 | input_body: undefined, 291 | input_body_path: undefined, 292 | input_draft: false, 293 | input_prerelease: true, 294 | input_preserve_order: undefined, 295 | input_files: [], 296 | input_name: undefined, 297 | input_tag_name: undefined, 298 | input_fail_on_unmatched_files: false, 299 | input_target_commitish: undefined, 300 | input_discussion_category_name: undefined, 301 | input_generate_release_notes: false, 302 | input_make_latest: undefined, 303 | }, 304 | ); 305 | }); 306 | it("parses basic config where make_latest is passed", () => { 307 | assert.deepStrictEqual( 308 | parseConfig({ 309 | INPUT_MAKE_LATEST: "false", 310 | }), 311 | { 312 | github_ref: "", 313 | github_repository: "", 314 | github_token: "", 315 | input_append_body: false, 316 | input_body: undefined, 317 | input_body_path: undefined, 318 | input_draft: undefined, 319 | input_prerelease: undefined, 320 | input_preserve_order: undefined, 321 | input_files: [], 322 | input_name: undefined, 323 | input_tag_name: undefined, 324 | input_fail_on_unmatched_files: false, 325 | input_target_commitish: undefined, 326 | input_discussion_category_name: undefined, 327 | input_generate_release_notes: false, 328 | input_make_latest: "false", 329 | }, 330 | ); 331 | }); 332 | it("parses basic config with append_body", () => { 333 | assert.deepStrictEqual( 334 | parseConfig({ 335 | INPUT_APPEND_BODY: "true", 336 | }), 337 | { 338 | github_ref: "", 339 | github_repository: "", 340 | github_token: "", 341 | input_append_body: true, 342 | input_body: undefined, 343 | input_body_path: undefined, 344 | input_draft: undefined, 345 | input_prerelease: undefined, 346 | input_preserve_order: undefined, 347 | input_files: [], 348 | input_name: undefined, 349 | input_tag_name: undefined, 350 | input_fail_on_unmatched_files: false, 351 | input_target_commitish: undefined, 352 | input_discussion_category_name: undefined, 353 | input_generate_release_notes: false, 354 | input_make_latest: undefined, 355 | }, 356 | ); 357 | }); 358 | }); 359 | describe("isTag", () => { 360 | it("returns true for tags", async () => { 361 | assert.equal(isTag("refs/tags/foo"), true); 362 | }); 363 | it("returns false for other kinds of refs", async () => { 364 | assert.equal(isTag("refs/heads/master"), false); 365 | }); 366 | }); 367 | 368 | describe("paths", () => { 369 | it("resolves files given a set of paths", async () => { 370 | assert.deepStrictEqual( 371 | paths(["tests/data/**/*", "tests/data/does/not/exist/*"]), 372 | ["tests/data/foo/bar.txt"], 373 | ); 374 | }); 375 | }); 376 | 377 | describe("unmatchedPatterns", () => { 378 | it("returns the patterns that don't match any files", async () => { 379 | assert.deepStrictEqual( 380 | unmatchedPatterns(["tests/data/**/*", "tests/data/does/not/exist/*"]), 381 | ["tests/data/does/not/exist/*"], 382 | ); 383 | }); 384 | }); 385 | 386 | describe("replaceSpacesWithDots", () => { 387 | it("replaces all spaces with dots", () => { 388 | expect(alignAssetName("John Doe.bla")).toBe("John.Doe.bla"); 389 | }); 390 | 391 | it("handles names with multiple spaces", () => { 392 | expect(alignAssetName("John William Doe.bla")).toBe( 393 | "John.William.Doe.bla", 394 | ); 395 | }); 396 | 397 | it("returns the same string if there are no spaces", () => { 398 | expect(alignAssetName("JohnDoe")).toBe("JohnDoe"); 399 | }); 400 | }); 401 | }); 402 | -------------------------------------------------------------------------------- /action.yml: -------------------------------------------------------------------------------- 1 | # https://help.github.com/en/articles/metadata-syntax-for-github-actions 2 | name: "GH Release" 3 | description: "Github Action for creating Github Releases" 4 | author: "softprops" 5 | inputs: 6 | body: 7 | description: "Note-worthy description of changes in release" 8 | required: false 9 | body_path: 10 | description: "Path to load note-worthy description of changes in release from" 11 | required: false 12 | name: 13 | description: "Gives the release a custom name. Defaults to tag name" 14 | required: false 15 | tag_name: 16 | description: "Gives a tag name. Defaults to github.ref_name" 17 | required: false 18 | draft: 19 | description: "Creates a draft release. Defaults to false" 20 | required: false 21 | prerelease: 22 | description: "Identify the release as a prerelease. Defaults to false" 23 | required: false 24 | preserve_order: 25 | description: "Preserver the order of the artifacts when uploading" 26 | required: false 27 | files: 28 | description: "Newline-delimited list of path globs for asset files to upload" 29 | required: false 30 | fail_on_unmatched_files: 31 | description: "Fails if any of the `files` globs match nothing. Defaults to false" 32 | required: false 33 | repository: 34 | description: "Repository to make releases against, in / format" 35 | required: false 36 | token: 37 | description: "Authorized secret GitHub Personal Access Token. Defaults to github.token" 38 | required: false 39 | default: ${{ github.token }} 40 | target_commitish: 41 | description: "Commitish value that determines where the Git tag is created from. Can be any branch or commit SHA." 42 | required: false 43 | discussion_category_name: 44 | 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." 45 | required: false 46 | generate_release_notes: 47 | description: "Whether to automatically generate the name and body for this release. If name is specified, the specified name will be used; otherwise, a name will be automatically generated. If body is specified, the body will be pre-pended to the automatically generated notes." 48 | required: false 49 | append_body: 50 | description: "Append to existing body instead of overwriting it. Default is false." 51 | required: false 52 | make_latest: 53 | description: "Specifies whether this release should be set as the latest release for the repository. Drafts and prereleases cannot be set as latest. Can be `true`, `false`, or `legacy`. Uses GitHub api default if not provided" 54 | required: false 55 | env: 56 | GITHUB_TOKEN: "As provided by Github Actions" 57 | outputs: 58 | url: 59 | description: "URL to the Release HTML Page" 60 | id: 61 | description: "Release ID" 62 | upload_url: 63 | description: "URL for uploading assets to the release" 64 | assets: 65 | description: "JSON array containing information about each uploaded asset, in the format given [here](https://docs.github.com/en/rest/reference/repos#upload-a-release-asset--code-samples) (minus the `uploader` field)" 66 | runs: 67 | using: "node20" 68 | main: "dist/index.js" 69 | branding: 70 | color: "green" 71 | icon: "package" 72 | -------------------------------------------------------------------------------- /demo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/softprops/action-gh-release/37fd9d0351a2df198244c8ef9f56d02d1f921e20/demo.png -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | import type { JestConfigWithTsJest } from 'ts-jest'; 2 | 3 | const config: JestConfigWithTsJest = { 4 | preset: 'ts-jest/presets/default-esm', 5 | clearMocks: true, 6 | moduleFileExtensions: ['js', 'ts'], 7 | testEnvironment: 'node', 8 | testMatch: ['**/*.test.ts'], 9 | testRunner: 'jest-circus/runner', 10 | transform: { 11 | '^.+\\.ts$': 'ts-jest', 12 | }, 13 | verbose: true, 14 | }; 15 | 16 | export default config; 17 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "action-gh-release", 3 | "version": "2.2.2", 4 | "private": true, 5 | "description": "GitHub Action for creating GitHub Releases", 6 | "main": "lib/main.js", 7 | "scripts": { 8 | "build": "ncc build src/main.ts --minify", 9 | "build-debug": "ncc build src/main.ts --v8-cache --source-map", 10 | "test": "jest", 11 | "fmt": "prettier --write \"src/**/*.ts\" \"__tests__/**/*.ts\"", 12 | "fmtcheck": "prettier --check \"src/**/*.ts\" \"__tests__/**/*.ts\"", 13 | "updatetag": "git tag -d v2 && git push origin :v2 && git tag -a v2 -m '' && git push origin v2" 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "git+https://github.com/softprops/action-gh-release.git" 18 | }, 19 | "keywords": [ 20 | "actions" 21 | ], 22 | "author": "softprops", 23 | "dependencies": { 24 | "@actions/core": "^1.11.1", 25 | "@actions/github": "^6.0.0", 26 | "@octokit/plugin-retry": "^7.2.1", 27 | "@octokit/plugin-throttling": "^10.0.0", 28 | "glob": "^11.0.2", 29 | "mime": "^3.0.0" 30 | }, 31 | "devDependencies": { 32 | "@types/glob": "^8.1.0", 33 | "@types/jest": "^29.5.14", 34 | "@types/mime": "^3.0.1", 35 | "@types/node": "^20.17.32", 36 | "@vercel/ncc": "^0.38.3", 37 | "jest": "^29.3.1", 38 | "jest-circus": "^29.3.1", 39 | "prettier": "3.5.3", 40 | "ts-jest": "^29.3.2", 41 | "ts-node": "^10.9.2", 42 | "typescript": "^5.8.3", 43 | "typescript-formatter": "^7.2.2" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/github.ts: -------------------------------------------------------------------------------- 1 | import { GitHub } from "@actions/github/lib/utils"; 2 | import { Config, isTag, releaseBody, alignAssetName } from "./util"; 3 | import { statSync } from "fs"; 4 | import { open } from "fs/promises"; 5 | import { getType } from "mime"; 6 | import { basename } from "path"; 7 | 8 | type GitHub = InstanceType; 9 | 10 | export interface ReleaseAsset { 11 | name: string; 12 | mime: string; 13 | size: number; 14 | } 15 | 16 | export interface Release { 17 | id: number; 18 | upload_url: string; 19 | html_url: string; 20 | tag_name: string; 21 | name: string | null; 22 | body?: string | null | undefined; 23 | target_commitish: string; 24 | draft: boolean; 25 | prerelease: boolean; 26 | assets: Array<{ id: number; name: string }>; 27 | } 28 | 29 | export interface Releaser { 30 | getReleaseByTag(params: { 31 | owner: string; 32 | repo: string; 33 | tag: string; 34 | }): Promise<{ data: Release }>; 35 | 36 | createRelease(params: { 37 | owner: string; 38 | repo: string; 39 | tag_name: string; 40 | name: string; 41 | body: string | undefined; 42 | draft: boolean | undefined; 43 | prerelease: boolean | undefined; 44 | target_commitish: string | undefined; 45 | discussion_category_name: string | undefined; 46 | generate_release_notes: boolean | undefined; 47 | make_latest: "true" | "false" | "legacy" | undefined; 48 | }): Promise<{ data: Release }>; 49 | 50 | updateRelease(params: { 51 | owner: string; 52 | repo: string; 53 | release_id: number; 54 | tag_name: string; 55 | target_commitish: string; 56 | name: string; 57 | body: string | undefined; 58 | draft: boolean | undefined; 59 | prerelease: boolean | undefined; 60 | discussion_category_name: string | undefined; 61 | generate_release_notes: boolean | undefined; 62 | make_latest: "true" | "false" | "legacy" | undefined; 63 | }): Promise<{ data: Release }>; 64 | 65 | allReleases(params: { 66 | owner: string; 67 | repo: string; 68 | }): AsyncIterableIterator<{ data: Release[] }>; 69 | } 70 | 71 | export class GitHubReleaser implements Releaser { 72 | github: GitHub; 73 | constructor(github: GitHub) { 74 | this.github = github; 75 | } 76 | 77 | getReleaseByTag(params: { 78 | owner: string; 79 | repo: string; 80 | tag: string; 81 | }): Promise<{ data: Release }> { 82 | return this.github.rest.repos.getReleaseByTag(params); 83 | } 84 | 85 | createRelease(params: { 86 | owner: string; 87 | repo: string; 88 | tag_name: string; 89 | name: string; 90 | body: string | undefined; 91 | draft: boolean | undefined; 92 | prerelease: boolean | undefined; 93 | target_commitish: string | undefined; 94 | discussion_category_name: string | undefined; 95 | generate_release_notes: boolean | undefined; 96 | make_latest: "true" | "false" | "legacy" | undefined; 97 | }): Promise<{ data: Release }> { 98 | if ( 99 | typeof params.make_latest === "string" && 100 | !["true", "false", "legacy"].includes(params.make_latest) 101 | ) { 102 | params.make_latest = undefined; 103 | } 104 | 105 | return this.github.rest.repos.createRelease(params); 106 | } 107 | 108 | updateRelease(params: { 109 | owner: string; 110 | repo: string; 111 | release_id: number; 112 | tag_name: string; 113 | target_commitish: string; 114 | name: string; 115 | body: string | undefined; 116 | draft: boolean | undefined; 117 | prerelease: boolean | undefined; 118 | discussion_category_name: string | undefined; 119 | generate_release_notes: boolean | undefined; 120 | make_latest: "true" | "false" | "legacy" | undefined; 121 | }): Promise<{ data: Release }> { 122 | if ( 123 | typeof params.make_latest === "string" && 124 | !["true", "false", "legacy"].includes(params.make_latest) 125 | ) { 126 | params.make_latest = undefined; 127 | } 128 | 129 | return this.github.rest.repos.updateRelease(params); 130 | } 131 | 132 | allReleases(params: { 133 | owner: string; 134 | repo: string; 135 | }): AsyncIterableIterator<{ data: Release[] }> { 136 | const updatedParams = { per_page: 100, ...params }; 137 | return this.github.paginate.iterator( 138 | this.github.rest.repos.listReleases.endpoint.merge(updatedParams), 139 | ); 140 | } 141 | } 142 | 143 | export const asset = (path: string): ReleaseAsset => { 144 | return { 145 | name: basename(path), 146 | mime: mimeOrDefault(path), 147 | size: statSync(path).size, 148 | }; 149 | }; 150 | 151 | export const mimeOrDefault = (path: string): string => { 152 | return getType(path) || "application/octet-stream"; 153 | }; 154 | 155 | export const upload = async ( 156 | config: Config, 157 | github: GitHub, 158 | url: string, 159 | path: string, 160 | currentAssets: Array<{ id: number; name: string }>, 161 | ): Promise => { 162 | const [owner, repo] = config.github_repository.split("/"); 163 | const { name, mime, size } = asset(path); 164 | const currentAsset = currentAssets.find( 165 | // 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. 166 | // 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 167 | // see https://docs.github.com/en/rest/releases/assets?apiVersion=2022-11-28#upload-a-release-asset 168 | ({ name: currentName }) => currentName == alignAssetName(name), 169 | ); 170 | if (currentAsset) { 171 | console.log(`♻️ Deleting previously uploaded asset ${name}...`); 172 | await github.rest.repos.deleteReleaseAsset({ 173 | asset_id: currentAsset.id || 1, 174 | owner, 175 | repo, 176 | }); 177 | } 178 | console.log(`⬆️ Uploading ${name}...`); 179 | const endpoint = new URL(url); 180 | endpoint.searchParams.append("name", name); 181 | const fh = await open(path); 182 | try { 183 | const resp = await github.request({ 184 | method: "POST", 185 | url: endpoint.toString(), 186 | headers: { 187 | "content-length": `${size}`, 188 | "content-type": mime, 189 | authorization: `token ${config.github_token}`, 190 | }, 191 | data: fh.readableWebStream({ type: "bytes" }), 192 | }); 193 | const json = resp.data; 194 | if (resp.status !== 201) { 195 | throw new Error( 196 | `Failed to upload release asset ${name}. received status code ${ 197 | resp.status 198 | }\n${json.message}\n${JSON.stringify(json.errors)}`, 199 | ); 200 | } 201 | console.log(`✅ Uploaded ${name}`); 202 | return json; 203 | } finally { 204 | await fh.close(); 205 | } 206 | }; 207 | 208 | export const release = async ( 209 | config: Config, 210 | releaser: Releaser, 211 | maxRetries: number = 3, 212 | ): Promise => { 213 | if (maxRetries <= 0) { 214 | console.log(`❌ Too many retries. Aborting...`); 215 | throw new Error("Too many retries."); 216 | } 217 | 218 | const [owner, repo] = config.github_repository.split("/"); 219 | const tag = 220 | config.input_tag_name || 221 | (isTag(config.github_ref) 222 | ? config.github_ref.replace("refs/tags/", "") 223 | : ""); 224 | 225 | const discussion_category_name = config.input_discussion_category_name; 226 | const generate_release_notes = config.input_generate_release_notes; 227 | try { 228 | const _release: Release | undefined = await findTagFromReleases( 229 | releaser, 230 | owner, 231 | repo, 232 | tag, 233 | ); 234 | 235 | if (_release === undefined) { 236 | return await createRelease( 237 | tag, 238 | config, 239 | releaser, 240 | owner, 241 | repo, 242 | discussion_category_name, 243 | generate_release_notes, 244 | maxRetries, 245 | ); 246 | } 247 | 248 | let existingRelease: Release = _release!; 249 | console.log( 250 | `Found release ${existingRelease.name} (with id=${existingRelease.id})`, 251 | ); 252 | 253 | const release_id = existingRelease.id; 254 | let target_commitish: string; 255 | if ( 256 | config.input_target_commitish && 257 | config.input_target_commitish !== existingRelease.target_commitish 258 | ) { 259 | console.log( 260 | `Updating commit from "${existingRelease.target_commitish}" to "${config.input_target_commitish}"`, 261 | ); 262 | target_commitish = config.input_target_commitish; 263 | } else { 264 | target_commitish = existingRelease.target_commitish; 265 | } 266 | 267 | const tag_name = tag; 268 | const name = config.input_name || existingRelease.name || tag; 269 | // revisit: support a new body-concat-strategy input for accumulating 270 | // body parts as a release gets updated. some users will likely want this while 271 | // others won't previously this was duplicating content for most which 272 | // no one wants 273 | const workflowBody = releaseBody(config) || ""; 274 | const existingReleaseBody = existingRelease.body || ""; 275 | let body: string; 276 | if (config.input_append_body && workflowBody && existingReleaseBody) { 277 | body = existingReleaseBody + "\n" + workflowBody; 278 | } else { 279 | body = workflowBody || existingReleaseBody; 280 | } 281 | 282 | const draft = 283 | config.input_draft !== undefined 284 | ? config.input_draft 285 | : existingRelease.draft; 286 | const prerelease = 287 | config.input_prerelease !== undefined 288 | ? config.input_prerelease 289 | : existingRelease.prerelease; 290 | 291 | const make_latest = config.input_make_latest; 292 | 293 | const release = await releaser.updateRelease({ 294 | owner, 295 | repo, 296 | release_id, 297 | tag_name, 298 | target_commitish, 299 | name, 300 | body, 301 | draft, 302 | prerelease, 303 | discussion_category_name, 304 | generate_release_notes, 305 | make_latest, 306 | }); 307 | return release.data; 308 | } catch (error) { 309 | if (error.status !== 404) { 310 | console.log( 311 | `⚠️ Unexpected error fetching GitHub release for tag ${config.github_ref}: ${error}`, 312 | ); 313 | throw error; 314 | } 315 | 316 | return await createRelease( 317 | tag, 318 | config, 319 | releaser, 320 | owner, 321 | repo, 322 | discussion_category_name, 323 | generate_release_notes, 324 | maxRetries, 325 | ); 326 | } 327 | }; 328 | 329 | /** 330 | * Finds a release by tag name from all a repository's releases. 331 | * 332 | * @param releaser - The GitHub API wrapper for release operations 333 | * @param owner - The owner of the repository 334 | * @param repo - The name of the repository 335 | * @param tag - The tag name to search for 336 | * @returns The release with the given tag name, or undefined if no release with that tag name is found 337 | */ 338 | export async function findTagFromReleases( 339 | releaser: Releaser, 340 | owner: string, 341 | repo: string, 342 | tag: string, 343 | ): Promise { 344 | for await (const { data: releases } of releaser.allReleases({ 345 | owner, 346 | repo, 347 | })) { 348 | const release = releases.find((release) => release.tag_name === tag); 349 | if (release) { 350 | return release; 351 | } 352 | } 353 | return undefined; 354 | } 355 | 356 | async function createRelease( 357 | tag: string, 358 | config: Config, 359 | releaser: Releaser, 360 | owner: string, 361 | repo: string, 362 | discussion_category_name: string | undefined, 363 | generate_release_notes: boolean | undefined, 364 | maxRetries: number, 365 | ) { 366 | const tag_name = tag; 367 | const name = config.input_name || tag; 368 | const body = releaseBody(config); 369 | const draft = config.input_draft; 370 | const prerelease = config.input_prerelease; 371 | const target_commitish = config.input_target_commitish; 372 | const make_latest = config.input_make_latest; 373 | let commitMessage: string = ""; 374 | if (target_commitish) { 375 | commitMessage = ` using commit "${target_commitish}"`; 376 | } 377 | console.log( 378 | `👩‍🏭 Creating new GitHub release for tag ${tag_name}${commitMessage}...`, 379 | ); 380 | try { 381 | let release = await releaser.createRelease({ 382 | owner, 383 | repo, 384 | tag_name, 385 | name, 386 | body, 387 | draft, 388 | prerelease, 389 | target_commitish, 390 | discussion_category_name, 391 | generate_release_notes, 392 | make_latest, 393 | }); 394 | return release.data; 395 | } catch (error) { 396 | // presume a race with competing matrix runs 397 | console.log(`⚠️ GitHub release failed with status: ${error.status}`); 398 | console.log(`${JSON.stringify(error.response.data)}`); 399 | 400 | switch (error.status) { 401 | case 403: 402 | console.log( 403 | "Skip retry — your GitHub token/PAT does not have the required permission to create a release", 404 | ); 405 | throw error; 406 | 407 | case 404: 408 | console.log("Skip retry - discussion category mismatch"); 409 | throw error; 410 | 411 | case 422: 412 | console.log("Skip retry - validation failed"); 413 | throw error; 414 | } 415 | 416 | console.log(`retrying... (${maxRetries - 1} retries remaining)`); 417 | return release(config, releaser, maxRetries - 1); 418 | } 419 | } 420 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { 2 | paths, 3 | parseConfig, 4 | isTag, 5 | unmatchedPatterns, 6 | uploadUrl, 7 | } from "./util"; 8 | import { release, upload, GitHubReleaser } from "./github"; 9 | import { getOctokit } from "@actions/github"; 10 | import { setFailed, setOutput } from "@actions/core"; 11 | 12 | import { env } from "process"; 13 | 14 | async function run() { 15 | try { 16 | const config = parseConfig(env); 17 | if ( 18 | !config.input_tag_name && 19 | !isTag(config.github_ref) && 20 | !config.input_draft 21 | ) { 22 | throw new Error(`⚠️ GitHub Releases requires a tag`); 23 | } 24 | if (config.input_files) { 25 | const patterns = unmatchedPatterns(config.input_files); 26 | patterns.forEach((pattern) => { 27 | if (config.input_fail_on_unmatched_files) { 28 | throw new Error(`⚠️ Pattern '${pattern}' does not match any files.`); 29 | } else { 30 | console.warn(`🤔 Pattern '${pattern}' does not match any files.`); 31 | } 32 | }); 33 | if (patterns.length > 0 && config.input_fail_on_unmatched_files) { 34 | throw new Error(`⚠️ There were unmatched files`); 35 | } 36 | } 37 | 38 | // const oktokit = GitHub.plugin( 39 | // require("@octokit/plugin-throttling"), 40 | // require("@octokit/plugin-retry") 41 | // ); 42 | 43 | const gh = getOctokit(config.github_token, { 44 | //new oktokit( 45 | throttle: { 46 | onRateLimit: (retryAfter, options) => { 47 | console.warn( 48 | `Request quota exhausted for request ${options.method} ${options.url}`, 49 | ); 50 | if (options.request.retryCount === 0) { 51 | // only retries once 52 | console.log(`Retrying after ${retryAfter} seconds!`); 53 | return true; 54 | } 55 | }, 56 | onAbuseLimit: (retryAfter, options) => { 57 | // does not retry, only logs a warning 58 | console.warn( 59 | `Abuse detected for request ${options.method} ${options.url}`, 60 | ); 61 | }, 62 | }, 63 | }); 64 | //); 65 | const rel = await release(config, new GitHubReleaser(gh)); 66 | if (config.input_files && config.input_files.length > 0) { 67 | const files = paths(config.input_files); 68 | if (files.length == 0) { 69 | if (config.input_fail_on_unmatched_files) { 70 | throw new Error( 71 | `⚠️ ${config.input_files} does not include a valid file.`, 72 | ); 73 | } else { 74 | console.warn( 75 | `🤔 ${config.input_files} does not include a valid file.`, 76 | ); 77 | } 78 | } 79 | const currentAssets = rel.assets; 80 | 81 | const uploadFile = async (path) => { 82 | const json = await upload( 83 | config, 84 | gh, 85 | uploadUrl(rel.upload_url), 86 | path, 87 | currentAssets, 88 | ); 89 | delete json.uploader; 90 | return json; 91 | }; 92 | 93 | let assets; 94 | if (!config.input_preserve_order) { 95 | assets = await Promise.all(files.map(uploadFile)); 96 | } else { 97 | assets = []; 98 | for (const path of files) { 99 | assets.push(await uploadFile(path)); 100 | } 101 | } 102 | setOutput("assets", assets); 103 | } 104 | console.log(`🎉 Release ready at ${rel.html_url}`); 105 | setOutput("url", rel.html_url); 106 | setOutput("id", rel.id.toString()); 107 | setOutput("upload_url", rel.upload_url); 108 | } catch (error) { 109 | setFailed(error.message); 110 | } 111 | } 112 | 113 | run(); 114 | -------------------------------------------------------------------------------- /src/util.ts: -------------------------------------------------------------------------------- 1 | import * as glob from "glob"; 2 | import { statSync, readFileSync } from "fs"; 3 | 4 | export interface Config { 5 | github_token: string; 6 | github_ref: string; 7 | github_repository: string; 8 | // user provided 9 | input_name?: string; 10 | input_tag_name?: string; 11 | input_repository?: string; 12 | input_body?: string; 13 | input_body_path?: string; 14 | input_files?: string[]; 15 | input_draft?: boolean; 16 | input_preserve_order?: boolean; 17 | input_prerelease?: boolean; 18 | input_fail_on_unmatched_files?: boolean; 19 | input_target_commitish?: string; 20 | input_discussion_category_name?: string; 21 | input_generate_release_notes?: boolean; 22 | input_append_body?: boolean; 23 | input_make_latest: "true" | "false" | "legacy" | undefined; 24 | } 25 | 26 | export const uploadUrl = (url: string): string => { 27 | const templateMarkerPos = url.indexOf("{"); 28 | if (templateMarkerPos > -1) { 29 | return url.substring(0, templateMarkerPos); 30 | } 31 | return url; 32 | }; 33 | 34 | export const releaseBody = (config: Config): string | undefined => { 35 | return ( 36 | (config.input_body_path && 37 | readFileSync(config.input_body_path).toString("utf8")) || 38 | config.input_body 39 | ); 40 | }; 41 | 42 | type Env = { [key: string]: string | undefined }; 43 | 44 | export const parseInputFiles = (files: string): string[] => { 45 | return files.split(/\r?\n/).reduce( 46 | (acc, line) => 47 | acc 48 | .concat(line.split(",")) 49 | .filter((pat) => pat) 50 | .map((pat) => pat.trim()), 51 | [], 52 | ); 53 | }; 54 | 55 | export const parseConfig = (env: Env): Config => { 56 | return { 57 | github_token: env.GITHUB_TOKEN || env.INPUT_TOKEN || "", 58 | github_ref: env.GITHUB_REF || "", 59 | github_repository: env.INPUT_REPOSITORY || env.GITHUB_REPOSITORY || "", 60 | input_name: env.INPUT_NAME, 61 | input_tag_name: env.INPUT_TAG_NAME?.trim(), 62 | input_body: env.INPUT_BODY, 63 | input_body_path: env.INPUT_BODY_PATH, 64 | input_files: parseInputFiles(env.INPUT_FILES || ""), 65 | input_draft: env.INPUT_DRAFT ? env.INPUT_DRAFT === "true" : undefined, 66 | input_preserve_order: env.INPUT_PRESERVE_ORDER 67 | ? env.INPUT_PRESERVE_ORDER == "true" 68 | : undefined, 69 | input_prerelease: env.INPUT_PRERELEASE 70 | ? env.INPUT_PRERELEASE == "true" 71 | : undefined, 72 | input_fail_on_unmatched_files: env.INPUT_FAIL_ON_UNMATCHED_FILES == "true", 73 | input_target_commitish: env.INPUT_TARGET_COMMITISH || undefined, 74 | input_discussion_category_name: 75 | env.INPUT_DISCUSSION_CATEGORY_NAME || undefined, 76 | input_generate_release_notes: env.INPUT_GENERATE_RELEASE_NOTES == "true", 77 | input_append_body: env.INPUT_APPEND_BODY == "true", 78 | input_make_latest: parseMakeLatest(env.INPUT_MAKE_LATEST), 79 | }; 80 | }; 81 | 82 | const parseMakeLatest = ( 83 | value: string | undefined, 84 | ): "true" | "false" | "legacy" | undefined => { 85 | if (value === "true" || value === "false" || value === "legacy") { 86 | return value; 87 | } 88 | return undefined; 89 | }; 90 | 91 | export const paths = (patterns: string[]): string[] => { 92 | return patterns.reduce((acc: string[], pattern: string): string[] => { 93 | return acc.concat( 94 | glob.sync(pattern).filter((path) => statSync(path).isFile()), 95 | ); 96 | }, []); 97 | }; 98 | 99 | export const unmatchedPatterns = (patterns: string[]): string[] => { 100 | return patterns.reduce((acc: string[], pattern: string): string[] => { 101 | return acc.concat( 102 | glob.sync(pattern).filter((path) => statSync(path).isFile()).length == 0 103 | ? [pattern] 104 | : [], 105 | ); 106 | }, []); 107 | }; 108 | 109 | export const isTag = (ref: string): boolean => { 110 | return ref.startsWith("refs/tags/"); 111 | }; 112 | 113 | export const alignAssetName = (assetName: string): string => { 114 | return assetName.replace(/ /g, "."); 115 | }; 116 | -------------------------------------------------------------------------------- /tests/data/foo/bar.txt: -------------------------------------------------------------------------------- 1 | release me -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "useUnknownInCatchVariables": false, 4 | /* Basic Options */ 5 | // "incremental": true, /* Enable incremental compilation */ 6 | "target": "es6", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */ 7 | "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */ 8 | // "allowJs": true, /* Allow javascript files to be compiled. */ 9 | // "checkJs": true, /* Report errors in .js files. */ 10 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 11 | // "declaration": true, /* Generates corresponding '.d.ts' file. */ 12 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 13 | // "sourceMap": true, /* Generates corresponding '.map' file. */ 14 | // "outFile": "./", /* Concatenate and emit output to single file. */ 15 | "outDir": "./lib", /* Redirect output structure to the directory. */ 16 | "rootDir": "./src", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 17 | // "composite": true, /* Enable project compilation */ 18 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ 19 | // "removeComments": true, /* Do not emit comments to output. */ 20 | // "noEmit": true, /* Do not emit outputs. */ 21 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 22 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 23 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 24 | 25 | /* Strict Type-Checking Options */ 26 | "strict": true, /* Enable all strict type-checking options. */ 27 | "noImplicitAny": false, /* Raise error on expressions and declarations with an implied 'any' type. */ 28 | // "strictNullChecks": true, /* Enable strict null checks. */ 29 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 30 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 31 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 32 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 33 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 34 | 35 | /* Additional Checks */ 36 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 37 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 38 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 39 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 40 | 41 | /* Module Resolution Options */ 42 | // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 43 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 44 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 45 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 46 | // "typeRoots": [], /* List of folders to include type definitions from. */ 47 | // "types": [], /* Type declaration files to be included in compilation. */ 48 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 49 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 50 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 51 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 52 | 53 | /* Source Map Options */ 54 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 55 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 56 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 57 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 58 | 59 | /* Experimental Options */ 60 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 61 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 62 | }, 63 | "exclude": ["node_modules", "**/*.test.ts", "jest.config.ts"] 64 | } 65 | --------------------------------------------------------------------------------