diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index 6861674..c76888a 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -1,14 +1,29 @@
version: 2
updates:
-- package-ecosystem: npm
- directory: "/"
- schedule:
- interval: weekly
- ignore:
- - dependency-name: node-fetch
- versions:
- - ">=3.0.0"
-- package-ecosystem: github-actions
- directory: "/"
- schedule:
- interval: weekly
+ - package-ecosystem: npm
+ 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)"
diff --git a/.github/release.yml b/.github/release.yml
new file mode 100644
index 0000000..7a9dcdb
--- /dev/null
+++ b/.github/release.yml
@@ -0,0 +1,22 @@
+changelog:
+ exclude:
+ labels:
+ - ignore-for-release
+ - github-actions
+ authors:
+ - octocat
+ - renovate[bot]
+ categories:
+ - title: Breaking Changes 🛠
+ labels:
+ - breaking-change
+ - title: Exciting New Features 🎉
+ labels:
+ - enhancement
+ - feature
+ - title: Bug fixes 🐛
+ labels:
+ - bug
+ - title: Other Changes 🔄
+ labels:
+ - "*"
diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
index 656167a..f85d3e2 100644
--- a/.github/workflows/main.yml
+++ b/.github/workflows/main.yml
@@ -1,14 +1,20 @@
-name: Main
+name: main
-on: [pull_request, push]
+on:
+ push:
+ pull_request:
jobs:
build:
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04
steps:
- # https://github.com/actions/checkout
- - name: Checkout
- uses: actions/checkout@v4
+ - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
+
+ - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
+ with:
+ node-version-file: ".tool-versions"
+ cache: "npm"
+
- name: Install
run: npm ci
- name: Build
diff --git a/.gitignore b/.gitignore
index 8856f55..6982a78 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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
diff --git a/.nvmrc b/.nvmrc
deleted file mode 100644
index 2dbbe00..0000000
--- a/.nvmrc
+++ /dev/null
@@ -1 +0,0 @@
-20.11.1
diff --git a/.tool-versions b/.tool-versions
new file mode 100644
index 0000000..f07031b
--- /dev/null
+++ b/.tool-versions
@@ -0,0 +1 @@
+nodejs 24.2.0
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 20ea9d8..f77a15c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,120 @@
+## 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 🔄
+* chore(deps): bump prettier from 2.8.0 to 3.3.3 by @dependabot in https://github.com/softprops/action-gh-release/pull/480
+* 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
+* 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
+* chore(deps): bump glob from 10.4.2 to 11.0.0 by @dependabot in https://github.com/softprops/action-gh-release/pull/477
+* refactor: write jest config in ts by @chenrui333 in https://github.com/softprops/action-gh-release/pull/485
+* 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
+
+## 2.0.7
+
+### Bug fixes 🐛
+
+* Fix missing update release body by @FirelightFlagboy in https://github.com/softprops/action-gh-release/pull/365
+
+### Other Changes 🔄
+
+* Bump @octokit/plugin-retry from 4.0.3 to 7.1.1 by @dependabot in https://github.com/softprops/action-gh-release/pull/443
+* Bump typescript from 4.9.5 to 5.5.2 by @dependabot in https://github.com/softprops/action-gh-release/pull/467
+* Bump @types/node from 20.14.6 to 20.14.8 by @dependabot in https://github.com/softprops/action-gh-release/pull/469
+* Bump @types/node from 20.14.8 to 20.14.9 by @dependabot in https://github.com/softprops/action-gh-release/pull/473
+* Bump typescript from 5.5.2 to 5.5.3 by @dependabot in https://github.com/softprops/action-gh-release/pull/472
+* Bump ts-jest from 29.1.5 to 29.2.2 by @dependabot in https://github.com/softprops/action-gh-release/pull/479
+* docs: document that existing releases are updated by @jvanbruegge in https://github.com/softprops/action-gh-release/pull/474
+
+## 2.0.6
+
+- maintenance release with updated dependencies
+
+## 2.0.5
+
+- 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)
+- Improvements to error handling [#449](https://github.com/softprops/action-gh-release/pull/449) via [@till](https://github.com/till)
+
## 2.0.4
- Minor follow up to [#417](https://github.com/softprops/action-gh-release/pull/417). [#425](https://github.com/softprops/action-gh-release/pull/425)
diff --git a/README.md b/README.md
index 212ad75..6e0622a 100644
--- a/README.md
+++ b/README.md
@@ -21,6 +21,16 @@
+- [🤸 Usage](#-usage)
+ - [🚥 Limit releases to pushes to tags](#-limit-releases-to-pushes-to-tags)
+ - [⬆️ Uploading release assets](#️-uploading-release-assets)
+ - [📝 External release notes](#-external-release-notes)
+ - [💅 Customizing](#-customizing)
+ - [inputs](#inputs)
+ - [outputs](#outputs)
+ - [environment variables](#environment-variables)
+ - [Permissions](#permissions)
+
## 🤸 Usage
### 🚥 Limit releases to pushes to tags
@@ -44,7 +54,7 @@ jobs:
uses: actions/checkout@v4
- 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
@@ -75,6 +85,7 @@ GitHub release and all are optional.
A common case for GitHub releases is to upload your binary after its been validated and packaged.
Use the `with.files` input to declare a newline-delimited list of glob expressions matching the files
you wish to upload to GitHub releases. If you'd like you can just list the files by name directly.
+If a tag already has a GitHub release, the existing release will be updated with the release assets.
Below is an example of uploading a single asset named `Release.txt`
@@ -95,7 +106,7 @@ jobs:
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
```
@@ -119,7 +130,7 @@ jobs:
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
@@ -151,14 +162,13 @@ jobs:
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
token: ${{ secrets.CUSTOM_GITHUB_TOKEN }}
- env:
- GITHUB_REPOSITORY: my_gh_org/my_gh_repo
```
### 💅 Customizing
@@ -173,9 +183,10 @@ 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 |
+| `preserve_order` | Boolean | Indicator of whether order of files should be preserved when uploading assets |
| `files` | String | Newline-delimited globs of paths to assets to upload for release |
| `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` |
| `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 `/` 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. |
diff --git a/__tests__/github.test.ts b/__tests__/github.test.ts
index 52c831d..26afabe 100644
--- a/__tests__/github.test.ts
+++ b/__tests__/github.test.ts
@@ -1,7 +1,13 @@
-//import * as assert from "assert";
-//const assert = require('assert');
import * as assert from "assert";
-import { mimeOrDefault, asset } from "../src/github";
+import {
+ mimeOrDefault,
+ asset,
+ Releaser,
+ Release,
+ findTagFromReleases,
+} from "../src/github";
+
+import { describe, it } from "vitest";
describe("github", () => {
describe("mimeOrDefault", () => {
@@ -15,11 +21,257 @@ describe("github", () => {
describe("asset", () => {
it("derives asset info from a path", async () => {
- const { name, mime, size, data } = asset("tests/data/foo/bar.txt");
+ 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("Not implemented"),
+ createRelease: () => Promise.reject("Not implemented"),
+ updateRelease: () => Promise.reject("Not implemented"),
+ allReleases: async function* () {
+ yield { data: [mockRelease] };
+ },
+ } as const;
+
+ describe("when the tag_name is not an empty string", () => {
+ const targetTag = "v1.0.0";
+
+ it("finds a matching release in first batch of results", async () => {
+ const targetRelease = {
+ ...mockRelease,
+ owner,
+ repo,
+ tag_name: targetTag,
+ };
+ const otherRelease = {
+ ...mockRelease,
+ owner,
+ repo,
+ tag_name: "v1.0.1",
+ };
+
+ const releaser = {
+ ...mockReleaser,
+ allReleases: async function* () {
+ yield { data: [targetRelease] };
+ yield { data: [otherRelease] };
+ },
+ };
+
+ const result = await findTagFromReleases(
+ releaser,
+ owner,
+ repo,
+ targetTag,
+ );
+
+ assert.deepStrictEqual(result, targetRelease);
+ });
+
+ it("finds a matching release in second batch of results", async () => {
+ const targetRelease = {
+ ...mockRelease,
+ owner,
+ repo,
+ tag_name: targetTag,
+ };
+ const otherRelease = {
+ ...mockRelease,
+ owner,
+ repo,
+ tag_name: "v1.0.1",
+ };
+
+ const releaser = {
+ ...mockReleaser,
+ allReleases: async function* () {
+ yield { data: [otherRelease] };
+ yield { data: [targetRelease] };
+ },
+ };
+
+ const result = await findTagFromReleases(
+ releaser,
+ owner,
+ repo,
+ targetTag,
+ );
+ assert.deepStrictEqual(result, targetRelease);
+ });
+
+ it("returns undefined when a release is not found in any batch", async () => {
+ const otherRelease = {
+ ...mockRelease,
+ owner,
+ repo,
+ tag_name: "v1.0.1",
+ };
+ const releaser = {
+ ...mockReleaser,
+ allReleases: async function* () {
+ yield { data: [otherRelease] };
+ yield { data: [otherRelease] };
+ },
+ };
+
+ const result = await findTagFromReleases(
+ releaser,
+ owner,
+ repo,
+ targetTag,
+ );
+
+ assert.strictEqual(result, undefined);
+ });
+
+ it("returns undefined when no releases are returned", async () => {
+ const releaser = {
+ ...mockReleaser,
+ allReleases: async function* () {
+ yield { data: [] };
+ },
+ };
+
+ const result = await findTagFromReleases(
+ releaser,
+ owner,
+ repo,
+ targetTag,
+ );
+
+ assert.strictEqual(result, undefined);
+ });
+ });
+
+ describe("when the tag_name is an empty string", () => {
+ const emptyTag = "";
+
+ it("finds a matching release in first batch of results", async () => {
+ const targetRelease = {
+ ...mockRelease,
+ owner,
+ repo,
+ tag_name: emptyTag,
+ };
+ const otherRelease = {
+ ...mockRelease,
+ owner,
+ repo,
+ tag_name: "v1.0.1",
+ };
+
+ const releaser = {
+ ...mockReleaser,
+ allReleases: async function* () {
+ yield { data: [targetRelease] };
+ yield { data: [otherRelease] };
+ },
+ };
+
+ const result = await findTagFromReleases(
+ releaser,
+ owner,
+ repo,
+ emptyTag,
+ );
+
+ assert.deepStrictEqual(result, targetRelease);
+ });
+
+ it("finds a matching release in second batch of results", async () => {
+ const targetRelease = {
+ ...mockRelease,
+ owner,
+ repo,
+ tag_name: emptyTag,
+ };
+ const otherRelease = {
+ ...mockRelease,
+ owner,
+ repo,
+ tag_name: "v1.0.1",
+ };
+
+ const releaser = {
+ ...mockReleaser,
+ allReleases: async function* () {
+ yield { data: [otherRelease] };
+ yield { data: [targetRelease] };
+ },
+ };
+
+ const result = await findTagFromReleases(
+ releaser,
+ owner,
+ repo,
+ emptyTag,
+ );
+ assert.deepStrictEqual(result, targetRelease);
+ });
+
+ it("returns undefined when a release is not found in any batch", async () => {
+ const otherRelease = {
+ ...mockRelease,
+ owner,
+ repo,
+ tag_name: "v1.0.1",
+ };
+ const releaser = {
+ ...mockReleaser,
+ allReleases: async function* () {
+ yield { data: [otherRelease] };
+ yield { data: [otherRelease] };
+ },
+ };
+
+ const result = await findTagFromReleases(
+ releaser,
+ owner,
+ repo,
+ emptyTag,
+ );
+
+ assert.strictEqual(result, undefined);
+ });
+
+ it("returns undefined when no releases are returned", async () => {
+ const releaser = {
+ ...mockReleaser,
+ allReleases: async function* () {
+ yield { data: [] };
+ },
+ };
+
+ const result = await findTagFromReleases(
+ releaser,
+ owner,
+ repo,
+ emptyTag,
+ );
+
+ assert.strictEqual(result, undefined);
+ });
});
});
});
diff --git a/__tests__/util.test.ts b/__tests__/util.test.ts
index 4f5e5a7..5fdccce 100644
--- a/__tests__/util.test.ts
+++ b/__tests__/util.test.ts
@@ -1,13 +1,16 @@
+import * as assert from "assert";
import {
- releaseBody,
+ alignAssetName,
isTag,
- paths,
parseConfig,
parseInputFiles,
+ paths,
+ releaseBody,
unmatchedPatterns,
uploadUrl,
} from "../src/util";
-import * as assert from "assert";
+
+import { describe, expect, it } from "vitest";
describe("util", () => {
describe("uploadUrl", () => {
@@ -46,6 +49,7 @@ describe("util", () => {
input_body_path: undefined,
input_draft: false,
input_prerelease: false,
+ input_preserve_order: undefined,
input_files: [],
input_name: undefined,
input_tag_name: undefined,
@@ -68,6 +72,7 @@ describe("util", () => {
input_body_path: "__tests__/release.txt",
input_draft: false,
input_prerelease: false,
+ input_preserve_order: undefined,
input_files: [],
input_name: undefined,
input_tag_name: undefined,
@@ -90,6 +95,7 @@ describe("util", () => {
input_body_path: "__tests__/release.txt",
input_draft: false,
input_prerelease: false,
+ input_preserve_order: undefined,
input_files: [],
input_name: undefined,
input_tag_name: undefined,
@@ -124,6 +130,7 @@ describe("util", () => {
input_body_path: undefined,
input_draft: undefined,
input_prerelease: undefined,
+ input_preserve_order: undefined,
input_files: [],
input_name: undefined,
input_tag_name: undefined,
@@ -152,6 +159,7 @@ describe("util", () => {
input_draft: undefined,
input_prerelease: undefined,
input_files: [],
+ input_preserve_order: undefined,
input_name: undefined,
input_tag_name: undefined,
input_fail_on_unmatched_files: false,
@@ -178,6 +186,7 @@ describe("util", () => {
input_draft: undefined,
input_prerelease: undefined,
input_files: [],
+ input_preserve_order: undefined,
input_name: undefined,
input_tag_name: undefined,
input_fail_on_unmatched_files: false,
@@ -204,6 +213,7 @@ describe("util", () => {
input_body_path: undefined,
input_draft: undefined,
input_prerelease: undefined,
+ input_preserve_order: undefined,
input_files: [],
input_name: undefined,
input_tag_name: undefined,
@@ -222,6 +232,7 @@ describe("util", () => {
parseConfig({
INPUT_DRAFT: "false",
INPUT_PRERELEASE: "true",
+ INPUT_PRESERVE_ORDER: "true",
GITHUB_TOKEN: "env-token",
INPUT_TOKEN: "input-token",
}),
@@ -234,6 +245,7 @@ describe("util", () => {
input_body_path: undefined,
input_draft: false,
input_prerelease: true,
+ input_preserve_order: true,
input_files: [],
input_name: undefined,
input_tag_name: undefined,
@@ -262,6 +274,7 @@ describe("util", () => {
input_body_path: undefined,
input_draft: false,
input_prerelease: true,
+ input_preserve_order: undefined,
input_files: [],
input_name: undefined,
input_tag_name: undefined,
@@ -289,6 +302,7 @@ describe("util", () => {
input_body_path: undefined,
input_draft: false,
input_prerelease: true,
+ input_preserve_order: undefined,
input_files: [],
input_name: undefined,
input_tag_name: undefined,
@@ -314,6 +328,7 @@ describe("util", () => {
input_body_path: undefined,
input_draft: undefined,
input_prerelease: undefined,
+ input_preserve_order: undefined,
input_files: [],
input_name: undefined,
input_tag_name: undefined,
@@ -340,6 +355,7 @@ describe("util", () => {
input_body_path: undefined,
input_draft: undefined,
input_prerelease: undefined,
+ input_preserve_order: undefined,
input_files: [],
input_name: undefined,
input_tag_name: undefined,
@@ -379,4 +395,20 @@ describe("util", () => {
);
});
});
+
+ 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");
+ });
+ });
});
diff --git a/action.yml b/action.yml
index fa0fb15..8cd7f8b 100644
--- a/action.yml
+++ b/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"
required: false
draft:
description: "Creates a draft release. Defaults to false"
@@ -21,6 +21,9 @@ inputs:
prerelease:
description: "Identify the release as a prerelease. Defaults to false"
required: false
+ preserve_order:
+ description: "Preserver the order of the artifacts when uploading"
+ required: false
files:
description: "Newline-delimited list of path globs for asset files to upload"
required: false
@@ -53,6 +56,8 @@ inputs:
make_latest:
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"
required: false
+env:
+ GITHUB_TOKEN: "As provided by Github Actions"
outputs:
url:
description: "URL to the Release HTML Page"
diff --git a/jest.config.js b/jest.config.js
deleted file mode 100644
index 563d4cc..0000000
--- a/jest.config.js
+++ /dev/null
@@ -1,11 +0,0 @@
-module.exports = {
- clearMocks: true,
- moduleFileExtensions: ['js', 'ts'],
- testEnvironment: 'node',
- testMatch: ['**/*.test.ts'],
- testRunner: 'jest-circus/runner',
- transform: {
- '^.+\\.ts$': 'ts-jest'
- },
- verbose: true
-}
\ No newline at end of file
diff --git a/src/github.ts b/src/github.ts
index d671284..8be6842 100644
--- a/src/github.ts
+++ b/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 mime from "mime";
+import { statSync } from "fs";
+import { open } from "fs/promises";
+import { lookup } from "mime-types";
import { basename } from "path";
+import { alignAssetName, Config, isTag, releaseBody } from "./util";
type GitHub = InstanceType;
@@ -10,12 +11,17 @@ export interface ReleaseAsset {
name: string;
mime: string;
size: number;
- data: Buffer;
}
-export type GenerateReleaseNotesParams = Partial[0]>;
-export type CreateReleaseParams = Partial[0]>;
-export type UpdateReleaseParams = Partial[0]>;
+export type GenerateReleaseNotesParams = Partial<
+ Parameters[0]
+>;
+export type CreateReleaseParams = Partial<
+ Parameters[0]
+>;
+export type UpdateReleaseParams = Partial<
+ Parameters[0]
+>;
export interface Release {
id: number;
@@ -51,9 +57,7 @@ export interface Releaser {
repo: string;
}): Promise;
- generateReleaseBody(
- params: GenerateReleaseNotesParams
- ): Promise;
+ generateReleaseBody(params: GenerateReleaseNotesParams): Promise;
}
export class GitHubReleaser implements Releaser {
@@ -73,7 +77,7 @@ export class GitHubReleaser implements Releaser {
createRelease(params: CreateReleaseParams): Promise<{ data: Release }> {
return this.github.rest.repos.createRelease({
...params,
- generate_release_notes: false
+ generate_release_notes: false,
} as any);
}
@@ -99,7 +103,9 @@ export class GitHubReleaser implements Releaser {
repo: string;
}): Promise {
try {
- const release = await this.github.rest.repos.getLatestRelease(params as any);
+ const release = await this.github.rest.repos.getLatestRelease(
+ params as any
+ );
if (!release?.data) {
return;
@@ -113,7 +119,9 @@ export class GitHubReleaser implements Releaser {
}
}
- async generateReleaseBody(params: GenerateReleaseNotesParams): Promise {
+ async generateReleaseBody(
+ params: GenerateReleaseNotesParams
+ ): Promise {
try {
const { data } = await this.github.rest.repos.generateReleaseNotes(
params as any
@@ -135,12 +143,11 @@ 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 mime.getType(path) || "application/octet-stream";
+ return lookup(path) || "application/octet-stream";
};
export const upload = async (
@@ -151,9 +158,12 @@ export const upload = async (
currentAssets: Array<{ id: number; name: string }>
): Promise => {
const [owner, repo] = config.github_repository.split("/");
- const { name, size, mime, data: body } = asset(path);
+ const { name, mime, size } = asset(path);
const currentAsset = currentAssets.find(
- ({ name: currentName }) => currentName == name
+ // 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 == alignAssetName(name)
);
if (currentAsset) {
console.log(`♻️ Deleting previously uploaded asset ${name}...`);
@@ -166,25 +176,31 @@ export const upload = async (
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)}`
- );
+ const fh = await open(path);
+ try {
+ const resp = await github.request({
+ method: "POST",
+ url: endpoint.toString(),
+ headers: {
+ "content-length": `${size}`,
+ "content-type": mime,
+ authorization: `token ${config.github_token}`,
+ },
+ data: fh.readableWebStream({ type: "bytes" }),
+ });
+ 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)}`
+ );
+ }
+ console.log(`✅ Uploaded ${name}`);
+ return json;
+ } finally {
+ await fh.close();
}
- return json;
};
export const release = async (
@@ -243,47 +259,54 @@ export const release = async (
body = body ? `${body}\n` : "";
try {
- // you can't get a an existing draft by tag
- // so we must find one in the list of all releases
- if (config.input_draft) {
- for await (const response of releaser.allReleases({
- owner,
- repo,
- })) {
- let release = response.data.find((release) => release.tag_name === tag);
- if (release) {
- return release;
- }
- }
- }
- let existingRelease = await releaser.getReleaseByTag({
+ const _release: Release | undefined = await findTagFromReleases(
+ releaser,
owner,
repo,
- tag,
- });
+ tag
+ );
- const release_id = existingRelease.data.id;
+ if (_release === undefined) {
+ return await createRelease(
+ tag,
+ config,
+ releaser,
+ owner,
+ repo,
+ discussion_category_name,
+ generate_release_notes,
+ maxRetries
+ );
+ }
+
+ let existingRelease: Release = _release!;
+ console.log(
+ `Found release ${existingRelease.name} (with id=${existingRelease.id})`
+ );
+
+ const release_id = existingRelease.id;
let target_commitish: string;
if (
config.input_target_commitish &&
- config.input_target_commitish !== existingRelease.data.target_commitish
+ config.input_target_commitish !== existingRelease.target_commitish
) {
console.log(
- `Updating commit from "${existingRelease.data.target_commitish}" to "${config.input_target_commitish}"`
+ `Updating commit from "${existingRelease.target_commitish}" to "${config.input_target_commitish}"`
);
target_commitish = config.input_target_commitish;
} else {
- target_commitish = existingRelease.data.target_commitish;
+ target_commitish = existingRelease.target_commitish;
}
- const name = config.input_name || existingRelease.data.name || tag;
+ const tag_name = tag;
+ const name = config.input_name || existingRelease.name || tag;
// revisit: support a new body-concat-strategy input for accumulating
// 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.data.body || "";
-
+ const existingReleaseBody = existingRelease.body || "";
+ let body: string;
if (config.input_append_body && workflowBody && existingReleaseBody) {
console.log("➕ Appending existing release body");
body = body + existingReleaseBody + "\n" + workflowBody;
@@ -297,11 +320,11 @@ export const release = async (
const draft =
config.input_draft !== undefined
? config.input_draft
- : existingRelease.data.draft;
+ : existingRelease.draft;
const prerelease =
config.input_prerelease !== undefined
? config.input_prerelease
- : existingRelease.data.prerelease;
+ : existingRelease.prerelease;
const make_latest = config.input_make_latest!;
@@ -372,5 +395,108 @@ export const release = async (
);
throw error;
}
+
+ return await createRelease(
+ tag,
+ config,
+ releaser,
+ owner,
+ repo,
+ discussion_category_name,
+ generate_release_notes,
+ maxRetries
+ );
}
};
+
+/**
+ * Finds a release by tag name from all a repository's 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 {
+ for await (const { data: releases } of releaser.allReleases({
+ owner,
+ repo,
+ })) {
+ const release = releases.find((release) => release.tag_name === tag);
+ if (release) {
+ return release;
+ }
+ }
+ return undefined;
+}
+
+async function createRelease(
+ tag: string,
+ config: Config,
+ releaser: Releaser,
+ owner: string,
+ repo: string,
+ discussion_category_name: string | undefined,
+ generate_release_notes: boolean | undefined,
+ maxRetries: number
+) {
+ 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 target_commitish = config.input_target_commitish;
+ const make_latest = config.input_make_latest;
+ let commitMessage: string = "";
+ if (target_commitish) {
+ commitMessage = ` using commit "${target_commitish}"`;
+ }
+ console.log(
+ `👩🏭 Creating new GitHub release for tag ${tag_name}${commitMessage}...`
+ );
+ try {
+ let release = await releaser.createRelease({
+ owner,
+ repo,
+ tag_name,
+ name,
+ body,
+ draft,
+ prerelease,
+ target_commitish,
+ discussion_category_name,
+ generate_release_notes,
+ make_latest,
+ });
+ return release.data;
+ } catch (error) {
+ // presume a race with competing matrix runs
+ console.log(`⚠️ GitHub release failed with status: ${error.status}`);
+ console.log(`${JSON.stringify(error.response.data)}`);
+
+ switch (error.status) {
+ case 403:
+ console.log(
+ "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");
+ throw error;
+
+ case 422:
+ console.log("Skip retry - validation failed");
+ throw error;
+ }
+
+ console.log(`retrying... (${maxRetries - 1} retries remaining)`);
+ return release(config, releaser, maxRetries - 1);
+ }
+}
diff --git a/src/main.ts b/src/main.ts
index 27400b3..4d37e9a 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -8,7 +8,6 @@ import {
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 { env } from "process";
@@ -46,7 +45,7 @@ async function run() {
throttle: {
onRateLimit: (retryAfter, options) => {
console.warn(
- `Request quota exhausted for request ${options.method} ${options.url}`
+ `Request quota exhausted for request ${options.method} ${options.url}`,
);
if (options.request.retryCount === 0) {
// only retries once
@@ -57,7 +56,7 @@ async function run() {
onAbuseLimit: (retryAfter, options) => {
// does not retry, only logs a warning
console.warn(
- `Abuse detected for request ${options.method} ${options.url}`
+ `Abuse detected for request ${options.method} ${options.url}`,
);
},
},
@@ -68,27 +67,38 @@ async function run() {
const files = paths(config.input_files);
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;
- });
+
+ const uploadFile = async (path) => {
+ const json = await upload(
+ config,
+ gh,
+ uploadUrl(rel.upload_url),
+ path,
+ currentAssets,
+ );
+ delete json.uploader;
+ return json;
+ };
+
+ let assets;
+ if (!config.input_preserve_order) {
+ assets = await Promise.all(files.map(uploadFile));
+ } else {
+ assets = [];
+ for (const path of files) {
+ assets.push(await uploadFile(path));
+ }
+ }
setOutput("assets", assets);
}
console.log(`🎉 Release ready at ${rel.html_url}`);
diff --git a/src/util.ts b/src/util.ts
index b59db8a..225058f 100644
--- a/src/util.ts
+++ b/src/util.ts
@@ -1,5 +1,5 @@
+import { readFileSync, statSync } from "fs";
import * as glob from "glob";
-import { statSync, readFileSync } from "fs";
export interface Config {
github_token: string;
@@ -13,6 +13,7 @@ export interface Config {
input_body_path?: string;
input_files?: string[];
input_draft?: boolean;
+ input_preserve_order?: boolean;
input_prerelease?: boolean;
input_fail_on_unmatched_files?: boolean;
input_target_commitish?: string;
@@ -63,6 +64,9 @@ export const parseConfig = (env: Env): Config => {
input_body_path: env.INPUT_BODY_PATH,
input_files: parseInputFiles(env.INPUT_FILES || ""),
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,
@@ -72,13 +76,20 @@ export const parseConfig = (env: Env): Config => {
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: env.INPUT_MAKE_LATEST
- ? env.INPUT_MAKE_LATEST
- : undefined,
+ input_make_latest: parseMakeLatest(env.INPUT_MAKE_LATEST),
input_previous_tag: env.INPUT_PREVIOUS_TAG?.trim() || undefined,
};
};
+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[] => {
return patterns.reduce((acc: string[], pattern: string): string[] => {
return acc.concat(
@@ -100,3 +111,7 @@ export const unmatchedPatterns = (patterns: string[]): string[] => {
export const isTag = (ref: string): boolean => {
return ref.startsWith("refs/tags/");
};
+
+export const alignAssetName = (assetName: string): string => {
+ return assetName.replace(/ /g, ".");
+};
diff --git a/tsconfig.json b/tsconfig.json
index a0724f0..6f6b113 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -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": ["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"]
-}
\ No newline at end of file
+ "exclude": ["node_modules", "**/*.test.ts", "vitest.config.ts"]
+}
diff --git a/vitest.config.ts b/vitest.config.ts
new file mode 100644
index 0000000..09a6bad
--- /dev/null
+++ b/vitest.config.ts
@@ -0,0 +1,11 @@
+import { defineConfig } from 'vitest/config';
+
+export default defineConfig({
+ test: {
+ environment: 'node',
+ coverage: {
+ reporter: ['text', 'lcov'],
+ },
+ include: ['__tests__/**/*.ts'],
+ },
+});