Compare commits

..

No commits in common. "master" and "v2.0.6" have entirely different histories.

22 changed files with 3930 additions and 2912 deletions

View file

@ -1,29 +1,14 @@
version: 2 version: 2
updates: updates:
- package-ecosystem: npm - package-ecosystem: npm
directory: "/" directory: "/"
schedule: schedule:
interval: weekly interval: weekly
groups: ignore:
npm: - dependency-name: node-fetch
patterns: versions:
- "*" - ">=3.0.0"
ignore: - package-ecosystem: github-actions
- dependency-name: node-fetch directory: "/"
versions: schedule:
- ">=3.0.0" interval: weekly
- 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)"

22
.github/release.yml vendored
View file

@ -1,22 +0,0 @@
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:
- "*"

View file

@ -1,20 +1,14 @@
name: main name: Main
on: on: [pull_request, push]
push:
pull_request:
jobs: jobs:
build: build:
runs-on: ubuntu-24.04 runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 # https://github.com/actions/checkout
- name: Checkout
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 uses: actions/checkout@v4
with:
node-version-file: ".tool-versions"
cache: "npm"
- name: Install - name: Install
run: npm ci run: npm ci
- name: Build - name: Build

1
.gitignore vendored
View file

@ -2,4 +2,3 @@ __tests__/runner/*
# actions requires a node_modules dir https://github.com/actions/toolkit/blob/master/docs/javascript-action.md#publish-a-releasesv1-action # 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 # but its recommended not to check these in https://github.com/actions/toolkit/blob/master/docs/action-versioning.md#recommendations
node_modules node_modules
coverage

1
.nvmrc Normal file
View file

@ -0,0 +1 @@
20.14.0

View file

@ -1,16 +0,0 @@
# Build outputs
dist/
lib/
coverage/
# Dependencies
node_modules/
# Misc
.github/
*.log
.DS_Store
__tests__/release.txt
# Package files
package-lock.json

View file

@ -1,11 +0,0 @@
/**
* @type {import('prettier').Config}
*/
module.exports = {
trailingComma: 'all',
tabWidth: 2,
semi: true,
singleQuote: true,
printWidth: 100,
bracketSpacing: true,
};

View file

@ -1 +0,0 @@
nodejs 24.2.0

View file

@ -1,111 +1,3 @@
## 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 ## 2.0.6
- maintenance release with updated dependencies - maintenance release with updated dependencies

View file

@ -21,16 +21,6 @@
<br /> <br />
- [🤸 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 ## 🤸 Usage
### 🚥 Limit releases to pushes to tags ### 🚥 Limit releases to pushes to tags
@ -54,7 +44,7 @@ jobs:
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Release - name: Release
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2
if: github.ref_type == 'tag' if: startsWith(github.ref, 'refs/tags/')
``` ```
You can also use push config tag filter You can also use push config tag filter
@ -85,7 +75,6 @@ GitHub release and all are optional.
A common case for GitHub releases is to upload your binary after its been validated and packaged. 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 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. 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` Below is an example of uploading a single asset named `Release.txt`
@ -106,7 +95,7 @@ jobs:
run: cat Release.txt run: cat Release.txt
- name: Release - name: Release
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2
if: github.ref_type == 'tag' if: startsWith(github.ref, 'refs/tags/')
with: with:
files: Release.txt files: Release.txt
``` ```
@ -130,7 +119,7 @@ jobs:
run: cat Release.txt run: cat Release.txt
- name: Release - name: Release
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2
if: github.ref_type == 'tag' if: startsWith(github.ref, 'refs/tags/')
with: with:
files: | files: |
Release.txt Release.txt
@ -162,7 +151,7 @@ jobs:
run: echo "# Good things have arrived" > ${{ github.workspace }}-CHANGELOG.txt run: echo "# Good things have arrived" > ${{ github.workspace }}-CHANGELOG.txt
- name: Release - name: Release
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2
if: github.ref_type == 'tag' if: startsWith(github.ref, 'refs/tags/')
with: with:
body_path: ${{ github.workspace }}-CHANGELOG.txt body_path: ${{ github.workspace }}-CHANGELOG.txt
repository: my_gh_org/my_gh_repo repository: my_gh_org/my_gh_repo
@ -183,11 +172,9 @@ The following are optional as `step.with` keys
| `body_path` | String | Path to load text communicating notable changes in this release | | `body_path` | String | Path to load text communicating notable changes in this release |
| `draft` | Boolean | Indicator of whether or not this release is a draft | | `draft` | Boolean | Indicator of whether or not this release is a draft |
| `prerelease` | Boolean | Indicator of whether or not is a prerelease | | `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 | | `files` | String | Newline-delimited globs of paths to assets to upload for release |
| `overwrite_files` | Boolean | Indicator of whether files should be overwritten when they already exist. Defaults to true |
| `name` | String | Name of the release. defaults to tag name | | `name` | String | Name of the release. defaults to tag name |
| `tag_name` | String | Name of a tag. defaults to `github.ref_name` | | `tag_name` | String | Name of a tag. defaults to `github.ref` |
| `fail_on_unmatched_files` | Boolean | Indicator of whether to fail if any of the `files` globs match nothing | | `fail_on_unmatched_files` | Boolean | Indicator of whether to fail if any of the `files` globs match nothing |
| `repository` | String | Name of a target repository in `<owner>/<repo>` format. Defaults to GITHUB_REPOSITORY env variable | | `repository` | String | Name of a target repository in `<owner>/<repo>` format. Defaults to GITHUB_REPOSITORY env variable |
| `target_commitish` | String | Commitish value that determines where the Git tag is created from. Can be any branch or commit SHA. Defaults to repository default branch. | | `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. |

View file

@ -1,230 +1,25 @@
import { asset, findTagFromReleases, mimeOrDefault, Release, Releaser } from '../src/github'; //import * as assert from "assert";
//const assert = require('assert');
import * as assert from "assert";
import { mimeOrDefault, asset } from "../src/github";
import { assert, describe, it } from 'vitest'; describe("github", () => {
describe("mimeOrDefault", () => {
describe('github', () => { it("returns a specific mime for common path", async () => {
describe('mimeOrDefault', () => { assert.equal(mimeOrDefault("foo.tar.gz"), "application/gzip");
it('returns a specific mime for common path', async () => {
assert.equal(mimeOrDefault('foo.tar.gz'), 'application/gzip');
}); });
it('returns default mime for uncommon path', async () => { it("returns default mime for uncommon path", async () => {
assert.equal(mimeOrDefault('foo.uncommon'), 'application/octet-stream'); assert.equal(mimeOrDefault("foo.uncommon"), "application/octet-stream");
}); });
}); });
describe('asset', () => { describe("asset", () => {
it('derives asset info from a path', async () => { it("derives asset info from a path", async () => {
const { name, mime, size } = asset('tests/data/foo/bar.txt'); const { name, mime, size, data } = asset("tests/data/foo/bar.txt");
assert.equal(name, 'bar.txt'); assert.equal(name, "bar.txt");
assert.equal(mime, 'text/plain'); assert.equal(mime, "text/plain");
assert.equal(size, 10); 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);
});
}); });
}); });
}); });

View file

@ -1,118 +1,106 @@
import { import {
alignAssetName, releaseBody,
isTag, isTag,
paths,
parseConfig, parseConfig,
parseInputFiles, parseInputFiles,
paths,
releaseBody,
unmatchedPatterns, unmatchedPatterns,
uploadUrl, uploadUrl,
} from '../src/util'; } from "../src/util";
import * as assert from "assert";
import { assert, describe, expect, it } from 'vitest'; describe("util", () => {
describe("uploadUrl", () => {
describe('util', () => { it("strips template", () => {
describe('uploadUrl', () => {
it('strips template', () => {
assert.equal( assert.equal(
uploadUrl( uploadUrl(
'https://uploads.github.com/repos/octocat/Hello-World/releases/1/assets{?name,label}', "https://uploads.github.com/repos/octocat/Hello-World/releases/1/assets{?name,label}"
), ),
'https://uploads.github.com/repos/octocat/Hello-World/releases/1/assets', "https://uploads.github.com/repos/octocat/Hello-World/releases/1/assets"
); );
}); });
}); });
describe('parseInputFiles', () => { describe("parseInputFiles", () => {
it('parses empty strings', () => { it("parses empty strings", () => {
assert.deepStrictEqual(parseInputFiles(''), []); assert.deepStrictEqual(parseInputFiles(""), []);
}); });
it('parses comma-delimited strings', () => { it("parses comma-delimited strings", () => {
assert.deepStrictEqual(parseInputFiles('foo,bar'), ['foo', 'bar']); assert.deepStrictEqual(parseInputFiles("foo,bar"), ["foo", "bar"]);
}); });
it('parses newline and comma-delimited (and then some)', () => { it("parses newline and comma-delimited (and then some)", () => {
assert.deepStrictEqual(parseInputFiles('foo,bar\nbaz,boom,\n\ndoom,loom '), [ assert.deepStrictEqual(
'foo', parseInputFiles("foo,bar\nbaz,boom,\n\ndoom,loom "),
'bar', ["foo", "bar", "baz", "boom", "doom", "loom"]
'baz', );
'boom',
'doom',
'loom',
]);
}); });
}); });
describe('releaseBody', () => { describe("releaseBody", () => {
it('uses input body', () => { it("uses input body", () => {
assert.equal( assert.equal(
'foo', "foo",
releaseBody({ releaseBody({
github_ref: '', github_ref: "",
github_repository: '', github_repository: "",
github_token: '', github_token: "",
input_body: 'foo', input_body: "foo",
input_body_path: undefined, input_body_path: undefined,
input_draft: false, input_draft: false,
input_prerelease: false, input_prerelease: false,
input_preserve_order: undefined,
input_files: [], input_files: [],
input_overwrite_files: undefined,
input_name: undefined, input_name: undefined,
input_tag_name: undefined, input_tag_name: undefined,
input_target_commitish: undefined, input_target_commitish: undefined,
input_discussion_category_name: undefined, input_discussion_category_name: undefined,
input_generate_release_notes: false, input_generate_release_notes: false,
input_make_latest: undefined, input_make_latest: undefined,
}), })
); );
}); });
it('uses input body path', () => { it("uses input body path", () => {
assert.equal( assert.equal(
'bar', "bar",
releaseBody({ releaseBody({
github_ref: '', github_ref: "",
github_repository: '', github_repository: "",
github_token: '', github_token: "",
input_body: undefined, input_body: undefined,
input_body_path: '__tests__/release.txt', input_body_path: "__tests__/release.txt",
input_draft: false, input_draft: false,
input_prerelease: false, input_prerelease: false,
input_preserve_order: undefined,
input_files: [], input_files: [],
input_overwrite_files: undefined,
input_name: undefined, input_name: undefined,
input_tag_name: undefined, input_tag_name: undefined,
input_target_commitish: undefined, input_target_commitish: undefined,
input_discussion_category_name: undefined, input_discussion_category_name: undefined,
input_generate_release_notes: false, input_generate_release_notes: false,
input_make_latest: undefined, input_make_latest: undefined,
}), })
); );
}); });
it('defaults to body path when both body and body path are provided', () => { it("defaults to body path when both body and body path are provided", () => {
assert.equal( assert.equal(
'bar', "bar",
releaseBody({ releaseBody({
github_ref: '', github_ref: "",
github_repository: '', github_repository: "",
github_token: '', github_token: "",
input_body: 'foo', input_body: "foo",
input_body_path: '__tests__/release.txt', input_body_path: "__tests__/release.txt",
input_draft: false, input_draft: false,
input_prerelease: false, input_prerelease: false,
input_preserve_order: undefined,
input_files: [], input_files: [],
input_overwrite_files: undefined,
input_name: undefined, input_name: undefined,
input_tag_name: undefined, input_tag_name: undefined,
input_target_commitish: undefined, input_target_commitish: undefined,
input_discussion_category_name: undefined, input_discussion_category_name: undefined,
input_generate_release_notes: false, input_generate_release_notes: false,
input_make_latest: undefined, input_make_latest: undefined,
}), })
); );
}); });
}); });
describe('parseConfig', () => { describe("parseConfig", () => {
it('parses basic config', () => { it("parses basic config", () => {
assert.deepStrictEqual( assert.deepStrictEqual(
parseConfig({ parseConfig({
// note: inputs declared in actions.yml, even when declared not required, // note: inputs declared in actions.yml, even when declared not required,
@ -121,21 +109,19 @@ describe('util', () => {
// as an empty string !== undefined in terms of what we pass to the api // as an empty string !== undefined in terms of what we pass to the api
// so we cover that in a test case here to ensure undefined values are actually // so we cover that in a test case here to ensure undefined values are actually
// resolved as undefined and not empty strings // resolved as undefined and not empty strings
INPUT_TARGET_COMMITISH: '', INPUT_TARGET_COMMITISH: "",
INPUT_DISCUSSION_CATEGORY_NAME: '', INPUT_DISCUSSION_CATEGORY_NAME: "",
}), }),
{ {
github_ref: '', github_ref: "",
github_repository: '', github_repository: "",
github_token: '', github_token: "",
input_append_body: false, input_append_body: false,
input_body: undefined, input_body: undefined,
input_body_path: undefined, input_body_path: undefined,
input_draft: undefined, input_draft: undefined,
input_prerelease: undefined, input_prerelease: undefined,
input_preserve_order: undefined,
input_files: [], input_files: [],
input_overwrite_files: undefined,
input_name: undefined, input_name: undefined,
input_tag_name: undefined, input_tag_name: undefined,
input_fail_on_unmatched_files: false, input_fail_on_unmatched_files: false,
@ -143,82 +129,76 @@ describe('util', () => {
input_discussion_category_name: undefined, input_discussion_category_name: undefined,
input_generate_release_notes: false, input_generate_release_notes: false,
input_make_latest: undefined, input_make_latest: undefined,
}, }
); );
}); });
it('parses basic config with commitish', () => { it("parses basic config with commitish", () => {
assert.deepStrictEqual( assert.deepStrictEqual(
parseConfig({ parseConfig({
INPUT_TARGET_COMMITISH: 'affa18ef97bc9db20076945705aba8c516139abd', INPUT_TARGET_COMMITISH: "affa18ef97bc9db20076945705aba8c516139abd",
}), }),
{ {
github_ref: '', github_ref: "",
github_repository: '', github_repository: "",
github_token: '', github_token: "",
input_append_body: false, input_append_body: false,
input_body: undefined, input_body: undefined,
input_body_path: undefined, input_body_path: undefined,
input_draft: undefined, input_draft: undefined,
input_prerelease: undefined, input_prerelease: undefined,
input_files: [], input_files: [],
input_overwrite_files: undefined,
input_preserve_order: undefined,
input_name: undefined, input_name: undefined,
input_tag_name: undefined, input_tag_name: undefined,
input_fail_on_unmatched_files: false, input_fail_on_unmatched_files: false,
input_target_commitish: 'affa18ef97bc9db20076945705aba8c516139abd', input_target_commitish: "affa18ef97bc9db20076945705aba8c516139abd",
input_discussion_category_name: undefined, input_discussion_category_name: undefined,
input_generate_release_notes: false, input_generate_release_notes: false,
input_make_latest: undefined, input_make_latest: undefined,
}, }
); );
}); });
it('supports discussion category names', () => { it("supports discussion category names", () => {
assert.deepStrictEqual( assert.deepStrictEqual(
parseConfig({ parseConfig({
INPUT_DISCUSSION_CATEGORY_NAME: 'releases', INPUT_DISCUSSION_CATEGORY_NAME: "releases",
}), }),
{ {
github_ref: '', github_ref: "",
github_repository: '', github_repository: "",
github_token: '', github_token: "",
input_append_body: false, input_append_body: false,
input_body: undefined, input_body: undefined,
input_body_path: undefined, input_body_path: undefined,
input_draft: undefined, input_draft: undefined,
input_prerelease: undefined, input_prerelease: undefined,
input_files: [], input_files: [],
input_preserve_order: undefined,
input_name: undefined, input_name: undefined,
input_overwrite_files: undefined,
input_tag_name: undefined, input_tag_name: undefined,
input_fail_on_unmatched_files: false, input_fail_on_unmatched_files: false,
input_target_commitish: undefined, input_target_commitish: undefined,
input_discussion_category_name: 'releases', input_discussion_category_name: "releases",
input_generate_release_notes: false, input_generate_release_notes: false,
input_make_latest: undefined, input_make_latest: undefined,
}, }
); );
}); });
it('supports generating release notes', () => { it("supports generating release notes", () => {
assert.deepStrictEqual( assert.deepStrictEqual(
parseConfig({ parseConfig({
INPUT_GENERATE_RELEASE_NOTES: 'true', INPUT_GENERATE_RELEASE_NOTES: "true",
}), }),
{ {
github_ref: '', github_ref: "",
github_repository: '', github_repository: "",
github_token: '', github_token: "",
input_append_body: false, input_append_body: false,
input_body: undefined, input_body: undefined,
input_body_path: undefined, input_body_path: undefined,
input_draft: undefined, input_draft: undefined,
input_prerelease: undefined, input_prerelease: undefined,
input_preserve_order: undefined,
input_files: [], input_files: [],
input_overwrite_files: undefined,
input_name: undefined, input_name: undefined,
input_tag_name: undefined, input_tag_name: undefined,
input_fail_on_unmatched_files: false, input_fail_on_unmatched_files: false,
@ -226,31 +206,28 @@ describe('util', () => {
input_discussion_category_name: undefined, input_discussion_category_name: undefined,
input_generate_release_notes: true, input_generate_release_notes: true,
input_make_latest: undefined, input_make_latest: undefined,
}, }
); );
}); });
it('prefers GITHUB_TOKEN over token input for backwards compatibility', () => { it("prefers GITHUB_TOKEN over token input for backwards compatibility", () => {
assert.deepStrictEqual( assert.deepStrictEqual(
parseConfig({ parseConfig({
INPUT_DRAFT: 'false', INPUT_DRAFT: "false",
INPUT_PRERELEASE: 'true', INPUT_PRERELEASE: "true",
INPUT_PRESERVE_ORDER: 'true', GITHUB_TOKEN: "env-token",
GITHUB_TOKEN: 'env-token', INPUT_TOKEN: "input-token",
INPUT_TOKEN: 'input-token',
}), }),
{ {
github_ref: '', github_ref: "",
github_repository: '', github_repository: "",
github_token: 'env-token', github_token: "env-token",
input_append_body: false, input_append_body: false,
input_body: undefined, input_body: undefined,
input_body_path: undefined, input_body_path: undefined,
input_draft: false, input_draft: false,
input_prerelease: true, input_prerelease: true,
input_preserve_order: true,
input_files: [], input_files: [],
input_overwrite_files: undefined,
input_name: undefined, input_name: undefined,
input_tag_name: undefined, input_tag_name: undefined,
input_fail_on_unmatched_files: false, input_fail_on_unmatched_files: false,
@ -258,28 +235,26 @@ describe('util', () => {
input_discussion_category_name: undefined, input_discussion_category_name: undefined,
input_generate_release_notes: false, input_generate_release_notes: false,
input_make_latest: undefined, input_make_latest: undefined,
}, }
); );
}); });
it('uses input token as the source of GITHUB_TOKEN by default', () => { it("uses input token as the source of GITHUB_TOKEN by default", () => {
assert.deepStrictEqual( assert.deepStrictEqual(
parseConfig({ parseConfig({
INPUT_DRAFT: 'false', INPUT_DRAFT: "false",
INPUT_PRERELEASE: 'true', INPUT_PRERELEASE: "true",
INPUT_TOKEN: 'input-token', INPUT_TOKEN: "input-token",
}), }),
{ {
github_ref: '', github_ref: "",
github_repository: '', github_repository: "",
github_token: 'input-token', github_token: "input-token",
input_append_body: false, input_append_body: false,
input_body: undefined, input_body: undefined,
input_body_path: undefined, input_body_path: undefined,
input_draft: false, input_draft: false,
input_prerelease: true, input_prerelease: true,
input_preserve_order: undefined,
input_files: [], input_files: [],
input_overwrite_files: undefined,
input_name: undefined, input_name: undefined,
input_tag_name: undefined, input_tag_name: undefined,
input_fail_on_unmatched_files: false, input_fail_on_unmatched_files: false,
@ -287,27 +262,25 @@ describe('util', () => {
input_discussion_category_name: undefined, input_discussion_category_name: undefined,
input_generate_release_notes: false, input_generate_release_notes: false,
input_make_latest: undefined, input_make_latest: undefined,
}, }
); );
}); });
it('parses basic config with draft and prerelease', () => { it("parses basic config with draft and prerelease", () => {
assert.deepStrictEqual( assert.deepStrictEqual(
parseConfig({ parseConfig({
INPUT_DRAFT: 'false', INPUT_DRAFT: "false",
INPUT_PRERELEASE: 'true', INPUT_PRERELEASE: "true",
}), }),
{ {
github_ref: '', github_ref: "",
github_repository: '', github_repository: "",
github_token: '', github_token: "",
input_append_body: false, input_append_body: false,
input_body: undefined, input_body: undefined,
input_body_path: undefined, input_body_path: undefined,
input_draft: false, input_draft: false,
input_prerelease: true, input_prerelease: true,
input_preserve_order: undefined,
input_files: [], input_files: [],
input_overwrite_files: undefined,
input_name: undefined, input_name: undefined,
input_tag_name: undefined, input_tag_name: undefined,
input_fail_on_unmatched_files: false, input_fail_on_unmatched_files: false,
@ -315,53 +288,49 @@ describe('util', () => {
input_discussion_category_name: undefined, input_discussion_category_name: undefined,
input_generate_release_notes: false, input_generate_release_notes: false,
input_make_latest: undefined, input_make_latest: undefined,
}, }
); );
}); });
it('parses basic config where make_latest is passed', () => { it("parses basic config where make_latest is passed", () => {
assert.deepStrictEqual( assert.deepStrictEqual(
parseConfig({ parseConfig({
INPUT_MAKE_LATEST: 'false', INPUT_MAKE_LATEST: "false",
}), }),
{ {
github_ref: '', github_ref: "",
github_repository: '', github_repository: "",
github_token: '', github_token: "",
input_append_body: false, input_append_body: false,
input_body: undefined, input_body: undefined,
input_body_path: undefined, input_body_path: undefined,
input_draft: undefined, input_draft: undefined,
input_prerelease: undefined, input_prerelease: undefined,
input_preserve_order: undefined,
input_files: [], input_files: [],
input_name: undefined, input_name: undefined,
input_overwrite_files: undefined,
input_tag_name: undefined, input_tag_name: undefined,
input_fail_on_unmatched_files: false, input_fail_on_unmatched_files: false,
input_target_commitish: undefined, input_target_commitish: undefined,
input_discussion_category_name: undefined, input_discussion_category_name: undefined,
input_generate_release_notes: false, input_generate_release_notes: false,
input_make_latest: 'false', input_make_latest: "false",
}, }
); );
}); });
it('parses basic config with append_body', () => { it("parses basic config with append_body", () => {
assert.deepStrictEqual( assert.deepStrictEqual(
parseConfig({ parseConfig({
INPUT_APPEND_BODY: 'true', INPUT_APPEND_BODY: "true",
}), }),
{ {
github_ref: '', github_ref: "",
github_repository: '', github_repository: "",
github_token: '', github_token: "",
input_append_body: true, input_append_body: true,
input_body: undefined, input_body: undefined,
input_body_path: undefined, input_body_path: undefined,
input_draft: undefined, input_draft: undefined,
input_prerelease: undefined, input_prerelease: undefined,
input_preserve_order: undefined,
input_files: [], input_files: [],
input_overwrite_files: undefined,
input_name: undefined, input_name: undefined,
input_tag_name: undefined, input_tag_name: undefined,
input_fail_on_unmatched_files: false, input_fail_on_unmatched_files: false,
@ -369,47 +338,34 @@ describe('util', () => {
input_discussion_category_name: undefined, input_discussion_category_name: undefined,
input_generate_release_notes: false, input_generate_release_notes: false,
input_make_latest: undefined, input_make_latest: undefined,
}, }
); );
}); });
}); });
describe('isTag', () => { describe("isTag", () => {
it('returns true for tags', async () => { it("returns true for tags", async () => {
assert.equal(isTag('refs/tags/foo'), true); assert.equal(isTag("refs/tags/foo"), true);
}); });
it('returns false for other kinds of refs', async () => { it("returns false for other kinds of refs", async () => {
assert.equal(isTag('refs/heads/master'), false); assert.equal(isTag("refs/heads/master"), false);
}); });
}); });
describe('paths', () => { describe("paths", () => {
it('resolves files given a set of paths', async () => { it("resolves files given a set of paths", async () => {
assert.deepStrictEqual(paths(['tests/data/**/*', 'tests/data/does/not/exist/*']), [ assert.deepStrictEqual(
'tests/data/foo/bar.txt', paths(["tests/data/**/*", "tests/data/does/not/exist/*"]),
]); ["tests/data/foo/bar.txt"]
);
}); });
}); });
describe('unmatchedPatterns', () => { describe("unmatchedPatterns", () => {
it("returns the patterns that don't match any files", async () => { it("returns the patterns that don't match any files", async () => {
assert.deepStrictEqual( assert.deepStrictEqual(
unmatchedPatterns(['tests/data/**/*', 'tests/data/does/not/exist/*']), unmatchedPatterns(["tests/data/**/*", "tests/data/does/not/exist/*"]),
['tests/data/does/not/exist/*'], ["tests/data/does/not/exist/*"]
); );
}); });
}); });
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');
});
});
}); });

View file

@ -13,7 +13,7 @@ inputs:
description: "Gives the release a custom name. Defaults to tag name" description: "Gives the release a custom name. Defaults to tag name"
required: false required: false
tag_name: tag_name:
description: "Gives a tag name. Defaults to github.ref_name" description: "Gives a tag name. Defaults to github.GITHUB_REF"
required: false required: false
draft: draft:
description: "Creates a draft release. Defaults to false" description: "Creates a draft release. Defaults to false"
@ -21,16 +21,9 @@ inputs:
prerelease: prerelease:
description: "Identify the release as a prerelease. Defaults to false" description: "Identify the release as a prerelease. Defaults to false"
required: false required: false
preserve_order:
description: "Preserver the order of the artifacts when uploading"
required: false
files: files:
description: "Newline-delimited list of path globs for asset files to upload" description: "Newline-delimited list of path globs for asset files to upload"
required: false required: false
overwrite_files:
description: "Overwrite existing files with the same name. Defaults to true"
required: false
default: 'true'
fail_on_unmatched_files: fail_on_unmatched_files:
description: "Fails if any of the `files` globs match nothing. Defaults to false" description: "Fails if any of the `files` globs match nothing. Defaults to false"
required: false required: false
@ -57,7 +50,7 @@ inputs:
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" 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 required: false
env: env:
GITHUB_TOKEN: "As provided by Github Actions" "GITHUB_TOKEN": "As provided by Github Actions"
outputs: outputs:
url: url:
description: "URL to the Release HTML Page" description: "URL to the Release HTML Page"

23
dist/index.js vendored

File diff suppressed because one or more lines are too long

11
jest.config.js Normal file
View file

@ -0,0 +1,11 @@
module.exports = {
clearMocks: true,
moduleFileExtensions: ['js', 'ts'],
testEnvironment: 'node',
testMatch: ['**/*.test.ts'],
testRunner: 'jest-circus/runner',
transform: {
'^.+\\.ts$': 'ts-jest'
},
verbose: true
}

5489
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,14 +1,12 @@
{ {
"name": "action-gh-release", "name": "action-gh-release",
"version": "2.3.2", "version": "2.0.6",
"private": true, "private": true,
"description": "GitHub Action for creating GitHub Releases", "description": "GitHub Action for creating GitHub Releases",
"main": "lib/main.js", "main": "lib/main.js",
"scripts": { "scripts": {
"build": "ncc build src/main.ts --minify --target es2022", "build": "ncc build src/main.ts --minify",
"build-debug": "ncc build src/main.ts --v8-cache --source-map", "test": "jest",
"typecheck": "tsc --noEmit",
"test": "vitest --coverage",
"fmt": "prettier --write \"src/**/*.ts\" \"__tests__/**/*.ts\"", "fmt": "prettier --write \"src/**/*.ts\" \"__tests__/**/*.ts\"",
"fmtcheck": "prettier --check \"src/**/*.ts\" \"__tests__/**/*.ts\"", "fmtcheck": "prettier --check \"src/**/*.ts\" \"__tests__/**/*.ts\"",
"updatetag": "git tag -d v2 && git push origin :v2 && git tag -a v2 -m '' && git push origin v2" "updatetag": "git tag -d v2 && git push origin :v2 && git tag -a v2 -m '' && git push origin v2"
@ -22,23 +20,24 @@
], ],
"author": "softprops", "author": "softprops",
"dependencies": { "dependencies": {
"@actions/core": "^1.11.1", "@actions/core": "^1.10.0",
"@actions/github": "^6.0.1", "@actions/github": "^5.1.1",
"@octokit/plugin-retry": "^8.0.1", "@octokit/plugin-retry": "^4.0.3",
"@octokit/plugin-throttling": "^11.0.1", "@octokit/plugin-throttling": "^9.3.0",
"glob": "^11.0.3", "glob": "^10.4.2",
"mime-types": "^3.0.1" "mime": "^3.0.0"
}, },
"devDependencies": { "devDependencies": {
"@types/glob": "^8.1.0", "@types/glob": "^8.1.0",
"@types/mime-types": "^3.0.1", "@types/jest": "^29.5.12",
"@types/node": "^20.19.7", "@types/mime": "^3.0.1",
"@vercel/ncc": "^0.38.3", "@types/node": "^20.14.6",
"@vitest/coverage-v8": "^3.2.4", "@vercel/ncc": "^0.38.1",
"prettier": "3.6.2", "jest": "^29.3.1",
"ts-node": "^10.9.2", "jest-circus": "^29.3.1",
"typescript": "^5.8.3", "prettier": "2.8.0",
"typescript-formatter": "^7.2.2", "ts-jest": "^29.1.4",
"vitest": "^3.1.4" "typescript": "^4.9.3",
"typescript-formatter": "^7.2.2"
} }
} }

View file

@ -1,9 +1,8 @@
import { GitHub } from '@actions/github/lib/utils'; import { GitHub } from "@actions/github/lib/utils";
import { statSync } from 'fs'; import { Config, isTag, releaseBody } from "./util";
import { open } from 'fs/promises'; import { statSync, readFileSync } from "fs";
import { lookup } from 'mime-types'; import { getType } from "mime";
import { basename } from 'path'; import { basename } from "path";
import { alignAssetName, Config, isTag, releaseBody } from './util';
type GitHub = InstanceType<typeof GitHub>; type GitHub = InstanceType<typeof GitHub>;
@ -11,6 +10,7 @@ export interface ReleaseAsset {
name: string; name: string;
mime: string; mime: string;
size: number; size: number;
data: Buffer;
} }
export interface Release { export interface Release {
@ -27,7 +27,11 @@ export interface Release {
} }
export interface Releaser { export interface Releaser {
getReleaseByTag(params: { owner: string; repo: string; tag: string }): Promise<{ data: Release }>; getReleaseByTag(params: {
owner: string;
repo: string;
tag: string;
}): Promise<{ data: Release }>;
createRelease(params: { createRelease(params: {
owner: string; owner: string;
@ -40,7 +44,7 @@ export interface Releaser {
target_commitish: string | undefined; target_commitish: string | undefined;
discussion_category_name: string | undefined; discussion_category_name: string | undefined;
generate_release_notes: boolean | undefined; generate_release_notes: boolean | undefined;
make_latest: 'true' | 'false' | 'legacy' | undefined; make_latest: string | undefined;
}): Promise<{ data: Release }>; }): Promise<{ data: Release }>;
updateRelease(params: { updateRelease(params: {
@ -55,10 +59,13 @@ export interface Releaser {
prerelease: boolean | undefined; prerelease: boolean | undefined;
discussion_category_name: string | undefined; discussion_category_name: string | undefined;
generate_release_notes: boolean | undefined; generate_release_notes: boolean | undefined;
make_latest: 'true' | 'false' | 'legacy' | undefined; make_latest: string | undefined;
}): Promise<{ data: Release }>; }): Promise<{ data: Release }>;
allReleases(params: { owner: string; repo: string }): AsyncIterableIterator<{ data: Release[] }>; allReleases(params: {
owner: string;
repo: string;
}): AsyncIterableIterator<{ data: Release[] }>;
} }
export class GitHubReleaser implements Releaser { export class GitHubReleaser implements Releaser {
@ -86,15 +93,8 @@ export class GitHubReleaser implements Releaser {
target_commitish: string | undefined; target_commitish: string | undefined;
discussion_category_name: string | undefined; discussion_category_name: string | undefined;
generate_release_notes: boolean | undefined; generate_release_notes: boolean | undefined;
make_latest: 'true' | 'false' | 'legacy' | undefined; make_latest: string | undefined;
}): Promise<{ data: Release }> { }): Promise<{ data: Release }> {
if (
typeof params.make_latest === 'string' &&
!['true', 'false', 'legacy'].includes(params.make_latest)
) {
params.make_latest = undefined;
}
return this.github.rest.repos.createRelease(params); return this.github.rest.repos.createRelease(params);
} }
@ -110,22 +110,18 @@ export class GitHubReleaser implements Releaser {
prerelease: boolean | undefined; prerelease: boolean | undefined;
discussion_category_name: string | undefined; discussion_category_name: string | undefined;
generate_release_notes: boolean | undefined; generate_release_notes: boolean | undefined;
make_latest: 'true' | 'false' | 'legacy' | undefined; make_latest: string | undefined;
}): Promise<{ data: Release }> { }): Promise<{ data: Release }> {
if (
typeof params.make_latest === 'string' &&
!['true', 'false', 'legacy'].includes(params.make_latest)
) {
params.make_latest = undefined;
}
return this.github.rest.repos.updateRelease(params); return this.github.rest.repos.updateRelease(params);
} }
allReleases(params: { owner: string; repo: string }): AsyncIterableIterator<{ data: Release[] }> { allReleases(params: {
owner: string;
repo: string;
}): AsyncIterableIterator<{ data: Release[] }> {
const updatedParams = { per_page: 100, ...params }; const updatedParams = { per_page: 100, ...params };
return this.github.paginate.iterator( return this.github.paginate.iterator(
this.github.rest.repos.listReleases.endpoint.merge(updatedParams), this.github.rest.repos.listReleases.endpoint.merge(updatedParams)
); );
} }
} }
@ -135,11 +131,12 @@ export const asset = (path: string): ReleaseAsset => {
name: basename(path), name: basename(path),
mime: mimeOrDefault(path), mime: mimeOrDefault(path),
size: statSync(path).size, size: statSync(path).size,
data: readFileSync(path),
}; };
}; };
export const mimeOrDefault = (path: string): string => { export const mimeOrDefault = (path: string): string => {
return lookup(path) || 'application/octet-stream'; return getType(path) || "application/octet-stream";
}; };
export const upload = async ( export const upload = async (
@ -147,127 +144,124 @@ export const upload = async (
github: GitHub, github: GitHub,
url: string, url: string,
path: string, path: string,
currentAssets: Array<{ id: number; name: string }>, currentAssets: Array<{ id: number; name: string }>
): Promise<any> => { ): Promise<any> => {
const [owner, repo] = config.github_repository.split('/'); const [owner, repo] = config.github_repository.split("/");
const { name, mime, size } = asset(path); const { name, size, mime, data: body } = asset(path);
const currentAsset = currentAssets.find( const currentAsset = currentAssets.find(
// note: GitHub renames asset filenames that have special characters, non-alphanumeric characters, and leading or trailing periods. The "List release assets" endpoint lists the renamed filenames. // 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 // 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 // see https://docs.github.com/en/rest/releases/assets?apiVersion=2022-11-28#upload-a-release-asset
({ name: currentName }) => currentName == alignAssetName(name), ({ name: currentName }) => currentName == name.replace(" ", ".")
); );
if (currentAsset) { if (currentAsset) {
if (config.input_overwrite_files === false) { console.log(`♻️ Deleting previously uploaded asset ${name}...`);
console.log(`Asset ${name} already exists and overwrite_files is false...`); await github.rest.repos.deleteReleaseAsset({
return null; asset_id: currentAsset.id || 1,
} else { owner,
console.log(`♻️ Deleting previously uploaded asset ${name}...`); repo,
await github.rest.repos.deleteReleaseAsset({ });
asset_id: currentAsset.id || 1,
owner,
repo,
});
}
} }
console.log(`⬆️ Uploading ${name}...`); console.log(`⬆️ Uploading ${name}...`);
const endpoint = new URL(url); const endpoint = new URL(url);
endpoint.searchParams.append('name', name); endpoint.searchParams.append("name", name);
const fh = await open(path); const resp = await github.request({
try { method: "POST",
const resp = await github.request({ url: endpoint.toString(),
method: 'POST', headers: {
url: endpoint.toString(), "content-length": `${size}`,
headers: { "content-type": mime,
'content-length': `${size}`, authorization: `token ${config.github_token}`,
'content-type': mime, },
authorization: `token ${config.github_token}`, data: body,
}, });
data: fh.readableWebStream({ type: 'bytes' }), const json = resp.data;
}); if (resp.status !== 201) {
const json = resp.data; throw new Error(
if (resp.status !== 201) { `Failed to upload release asset ${name}. received status code ${
throw new Error( resp.status
`Failed to upload release asset ${name}. received status code ${ }\n${json.message}\n${JSON.stringify(json.errors)}`
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 ( export const release = async (
config: Config, config: Config,
releaser: Releaser, releaser: Releaser,
maxRetries: number = 3, maxRetries: number = 3
): Promise<Release> => { ): Promise<Release> => {
if (maxRetries <= 0) { if (maxRetries <= 0) {
console.log(`❌ Too many retries. Aborting...`); console.log(`❌ Too many retries. Aborting...`);
throw new Error('Too many retries.'); throw new Error("Too many retries.");
} }
const [owner, repo] = config.github_repository.split('/'); const [owner, repo] = config.github_repository.split("/");
const tag = const tag =
config.input_tag_name || config.input_tag_name ||
(isTag(config.github_ref) ? config.github_ref.replace('refs/tags/', '') : ''); (isTag(config.github_ref)
? config.github_ref.replace("refs/tags/", "")
: "");
const discussion_category_name = config.input_discussion_category_name; const discussion_category_name = config.input_discussion_category_name;
const generate_release_notes = config.input_generate_release_notes; const generate_release_notes = config.input_generate_release_notes;
try { try {
const _release: Release | undefined = await findTagFromReleases(releaser, owner, repo, tag); // you can't get a an existing draft by tag
// so we must find one in the list of all releases
if (_release === undefined) { if (config.input_draft) {
return await createRelease( for await (const response of releaser.allReleases({
tag,
config,
releaser,
owner, owner,
repo, repo,
discussion_category_name, })) {
generate_release_notes, let release = response.data.find((release) => release.tag_name === tag);
maxRetries, if (release) {
); return release;
}
}
} }
let existingRelease = await releaser.getReleaseByTag({
owner,
repo,
tag,
});
let existingRelease: Release = _release!; const release_id = existingRelease.data.id;
console.log(`Found release ${existingRelease.name} (with id=${existingRelease.id})`);
const release_id = existingRelease.id;
let target_commitish: string; let target_commitish: string;
if ( if (
config.input_target_commitish && config.input_target_commitish &&
config.input_target_commitish !== existingRelease.target_commitish config.input_target_commitish !== existingRelease.data.target_commitish
) { ) {
console.log( console.log(
`Updating commit from "${existingRelease.target_commitish}" to "${config.input_target_commitish}"`, `Updating commit from "${existingRelease.data.target_commitish}" to "${config.input_target_commitish}"`
); );
target_commitish = config.input_target_commitish; target_commitish = config.input_target_commitish;
} else { } else {
target_commitish = existingRelease.target_commitish; target_commitish = existingRelease.data.target_commitish;
} }
const tag_name = tag; const tag_name = tag;
const name = config.input_name || existingRelease.name || tag; const name = config.input_name || existingRelease.data.name || tag;
// revisit: support a new body-concat-strategy input for accumulating // revisit: support a new body-concat-strategy input for accumulating
// body parts as a release gets updated. some users will likely want this while // 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 // others won't previously this was duplicating content for most which
// no one wants // no one wants
const workflowBody = releaseBody(config) || ''; const workflowBody = releaseBody(config) || "";
const existingReleaseBody = existingRelease.body || ''; const existingReleaseBody = existingRelease.data.body || "";
let body: string; let body: string;
if (config.input_append_body && workflowBody && existingReleaseBody) { if (config.input_append_body && workflowBody && existingReleaseBody) {
body = existingReleaseBody + '\n' + workflowBody; body = existingReleaseBody + "\n" + workflowBody;
} else { } else {
body = workflowBody || existingReleaseBody; body = workflowBody || existingReleaseBody;
} }
const draft = config.input_draft !== undefined ? config.input_draft : existingRelease.draft; const draft =
config.input_draft !== undefined
? config.input_draft
: existingRelease.data.draft;
const prerelease = const prerelease =
config.input_prerelease !== undefined ? config.input_prerelease : existingRelease.prerelease; config.input_prerelease !== undefined
? config.input_prerelease
: existingRelease.data.prerelease;
const make_latest = config.input_make_latest; const make_latest = config.input_make_latest;
@ -289,110 +283,63 @@ export const release = async (
} catch (error) { } catch (error) {
if (error.status !== 404) { if (error.status !== 404) {
console.log( console.log(
`⚠️ Unexpected error fetching GitHub release for tag ${config.github_ref}: ${error}`, `⚠️ Unexpected error fetching GitHub release for tag ${config.github_ref}: ${error}`
); );
throw error; throw error;
} }
return await createRelease( const tag_name = tag;
tag, const name = config.input_name || tag;
config, const body = releaseBody(config);
releaser, const draft = config.input_draft;
owner, const prerelease = config.input_prerelease;
repo, const target_commitish = config.input_target_commitish;
discussion_category_name, const make_latest = config.input_make_latest;
generate_release_notes, let commitMessage: string = "";
maxRetries, 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);
}
} }
}; };
/**
* 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<Release | undefined> {
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);
}
}

View file

@ -1,14 +1,25 @@
import { setFailed, setOutput } from '@actions/core'; import {
import { getOctokit } from '@actions/github'; paths,
import { GitHubReleaser, release, upload } from './github'; parseConfig,
import { isTag, parseConfig, paths, unmatchedPatterns, uploadUrl } from './util'; isTag,
unmatchedPatterns,
uploadUrl,
} from "./util";
import { release, upload, GitHubReleaser } from "./github";
import { getOctokit } from "@actions/github";
import { setFailed, setOutput } from "@actions/core";
import { GitHub, getOctokitOptions } from "@actions/github/lib/utils";
import { env } from 'process'; import { env } from "process";
async function run() { async function run() {
try { try {
const config = parseConfig(env); const config = parseConfig(env);
if (!config.input_tag_name && !isTag(config.github_ref) && !config.input_draft) { if (
!config.input_tag_name &&
!isTag(config.github_ref) &&
!config.input_draft
) {
throw new Error(`⚠️ GitHub Releases requires a tag`); throw new Error(`⚠️ GitHub Releases requires a tag`);
} }
if (config.input_files) { if (config.input_files) {
@ -34,7 +45,9 @@ async function run() {
//new oktokit( //new oktokit(
throttle: { throttle: {
onRateLimit: (retryAfter, options) => { onRateLimit: (retryAfter, options) => {
console.warn(`Request quota exhausted for request ${options.method} ${options.url}`); console.warn(
`Request quota exhausted for request ${options.method} ${options.url}`
);
if (options.request.retryCount === 0) { if (options.request.retryCount === 0) {
// only retries once // only retries once
console.log(`Retrying after ${retryAfter} seconds!`); console.log(`Retrying after ${retryAfter} seconds!`);
@ -43,7 +56,9 @@ async function run() {
}, },
onAbuseLimit: (retryAfter, options) => { onAbuseLimit: (retryAfter, options) => {
// does not retry, only logs a warning // does not retry, only logs a warning
console.warn(`Abuse detected for request ${options.method} ${options.url}`); console.warn(
`Abuse detected for request ${options.method} ${options.url}`
);
}, },
}, },
}); });
@ -53,38 +68,33 @@ async function run() {
const files = paths(config.input_files); const files = paths(config.input_files);
if (files.length == 0) { if (files.length == 0) {
if (config.input_fail_on_unmatched_files) { if (config.input_fail_on_unmatched_files) {
throw new Error(`⚠️ ${config.input_files} does not include a valid file.`); throw new Error(`⚠️ ${config.input_files} not include valid file.`);
} else { } else {
console.warn(`🤔 ${config.input_files} does not include a valid file.`); console.warn(`🤔 ${config.input_files} not include valid file.`);
} }
} }
const currentAssets = rel.assets; const currentAssets = rel.assets;
const assets = await Promise.all(
const uploadFile = async (path) => { files.map(async (path) => {
const json = await upload(config, gh, uploadUrl(rel.upload_url), path, currentAssets); const json = await upload(
if (json) { config,
gh,
uploadUrl(rel.upload_url),
path,
currentAssets
);
delete json.uploader; delete json.uploader;
} return json;
return json; })
}; ).catch((error) => {
throw error;
let results: (any | null)[]; });
if (!config.input_preserve_order) { setOutput("assets", assets);
results = await Promise.all(files.map(uploadFile));
} else {
results = [];
for (const path of files) {
results.push(await uploadFile(path));
}
}
const assets = results.filter(Boolean);
setOutput('assets', assets);
} }
console.log(`🎉 Release ready at ${rel.html_url}`); console.log(`🎉 Release ready at ${rel.html_url}`);
setOutput('url', rel.html_url); setOutput("url", rel.html_url);
setOutput('id', rel.id.toString()); setOutput("id", rel.id.toString());
setOutput('upload_url', rel.upload_url); setOutput("upload_url", rel.upload_url);
} catch (error) { } catch (error) {
setFailed(error.message); setFailed(error.message);
} }

View file

@ -1,5 +1,5 @@
import * as glob from 'glob'; import * as glob from "glob";
import { statSync, readFileSync } from 'fs'; import { statSync, readFileSync } from "fs";
export interface Config { export interface Config {
github_token: string; github_token: string;
@ -12,20 +12,18 @@ export interface Config {
input_body?: string; input_body?: string;
input_body_path?: string; input_body_path?: string;
input_files?: string[]; input_files?: string[];
input_overwrite_files?: boolean;
input_draft?: boolean; input_draft?: boolean;
input_preserve_order?: boolean;
input_prerelease?: boolean; input_prerelease?: boolean;
input_fail_on_unmatched_files?: boolean; input_fail_on_unmatched_files?: boolean;
input_target_commitish?: string; input_target_commitish?: string;
input_discussion_category_name?: string; input_discussion_category_name?: string;
input_generate_release_notes?: boolean; input_generate_release_notes?: boolean;
input_append_body?: boolean; input_append_body?: boolean;
input_make_latest: 'true' | 'false' | 'legacy' | undefined; input_make_latest: string | undefined;
} }
export const uploadUrl = (url: string): string => { export const uploadUrl = (url: string): string => {
const templateMarkerPos = url.indexOf('{'); const templateMarkerPos = url.indexOf("{");
if (templateMarkerPos > -1) { if (templateMarkerPos > -1) {
return url.substring(0, templateMarkerPos); return url.substring(0, templateMarkerPos);
} }
@ -34,7 +32,8 @@ export const uploadUrl = (url: string): string => {
export const releaseBody = (config: Config): string | undefined => { export const releaseBody = (config: Config): string | undefined => {
return ( return (
(config.input_body_path && readFileSync(config.input_body_path).toString('utf8')) || (config.input_body_path &&
readFileSync(config.input_body_path).toString("utf8")) ||
config.input_body config.input_body
); );
}; };
@ -45,63 +44,57 @@ export const parseInputFiles = (files: string): string[] => {
return files.split(/\r?\n/).reduce<string[]>( return files.split(/\r?\n/).reduce<string[]>(
(acc, line) => (acc, line) =>
acc acc
.concat(line.split(',')) .concat(line.split(","))
.filter((pat) => pat) .filter((pat) => pat)
.map((pat) => pat.trim()), .map((pat) => pat.trim()),
[], []
); );
}; };
export const parseConfig = (env: Env): Config => { export const parseConfig = (env: Env): Config => {
return { return {
github_token: env.GITHUB_TOKEN || env.INPUT_TOKEN || '', github_token: env.GITHUB_TOKEN || env.INPUT_TOKEN || "",
github_ref: env.GITHUB_REF || '', github_ref: env.GITHUB_REF || "",
github_repository: env.INPUT_REPOSITORY || env.GITHUB_REPOSITORY || '', github_repository: env.INPUT_REPOSITORY || env.GITHUB_REPOSITORY || "",
input_name: env.INPUT_NAME, input_name: env.INPUT_NAME,
input_tag_name: env.INPUT_TAG_NAME?.trim(), input_tag_name: env.INPUT_TAG_NAME?.trim(),
input_body: env.INPUT_BODY, input_body: env.INPUT_BODY,
input_body_path: env.INPUT_BODY_PATH, input_body_path: env.INPUT_BODY_PATH,
input_files: parseInputFiles(env.INPUT_FILES || ''), input_files: parseInputFiles(env.INPUT_FILES || ""),
input_overwrite_files: env.INPUT_OVERWRITE_FILES input_draft: env.INPUT_DRAFT ? env.INPUT_DRAFT === "true" : undefined,
? env.INPUT_OVERWRITE_FILES == 'true' input_prerelease: env.INPUT_PRERELEASE
? env.INPUT_PRERELEASE == "true"
: undefined, : undefined,
input_draft: env.INPUT_DRAFT ? env.INPUT_DRAFT === 'true' : undefined, input_fail_on_unmatched_files: env.INPUT_FAIL_ON_UNMATCHED_FILES == "true",
input_preserve_order: env.INPUT_PRESERVE_ORDER ? env.INPUT_PRESERVE_ORDER == 'true' : undefined,
input_prerelease: env.INPUT_PRERELEASE ? env.INPUT_PRERELEASE == 'true' : undefined,
input_fail_on_unmatched_files: env.INPUT_FAIL_ON_UNMATCHED_FILES == 'true',
input_target_commitish: env.INPUT_TARGET_COMMITISH || undefined, input_target_commitish: env.INPUT_TARGET_COMMITISH || undefined,
input_discussion_category_name: env.INPUT_DISCUSSION_CATEGORY_NAME || undefined, input_discussion_category_name:
input_generate_release_notes: env.INPUT_GENERATE_RELEASE_NOTES == 'true', env.INPUT_DISCUSSION_CATEGORY_NAME || undefined,
input_append_body: env.INPUT_APPEND_BODY == 'true', input_generate_release_notes: env.INPUT_GENERATE_RELEASE_NOTES == "true",
input_make_latest: parseMakeLatest(env.INPUT_MAKE_LATEST), input_append_body: env.INPUT_APPEND_BODY == "true",
input_make_latest: env.INPUT_MAKE_LATEST
? env.INPUT_MAKE_LATEST
: 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[] => { export const paths = (patterns: string[]): string[] => {
return patterns.reduce((acc: string[], pattern: string): string[] => { return patterns.reduce((acc: string[], pattern: string): string[] => {
return acc.concat(glob.sync(pattern).filter((path) => statSync(path).isFile())); return acc.concat(
glob.sync(pattern).filter((path) => statSync(path).isFile())
);
}, []); }, []);
}; };
export const unmatchedPatterns = (patterns: string[]): string[] => { export const unmatchedPatterns = (patterns: string[]): string[] => {
return patterns.reduce((acc: string[], pattern: string): string[] => { return patterns.reduce((acc: string[], pattern: string): string[] => {
return acc.concat( return acc.concat(
glob.sync(pattern).filter((path) => statSync(path).isFile()).length == 0 ? [pattern] : [], glob.sync(pattern).filter((path) => statSync(path).isFile()).length == 0
? [pattern]
: []
); );
}, []); }, []);
}; };
export const isTag = (ref: string): boolean => { export const isTag = (ref: string): boolean => {
return ref.startsWith('refs/tags/'); return ref.startsWith("refs/tags/");
};
export const alignAssetName = (assetName: string): string => {
return assetName.replace(/ /g, '.');
}; };

View file

@ -3,8 +3,8 @@
"useUnknownInCatchVariables": false, "useUnknownInCatchVariables": false,
/* Basic Options */ /* Basic Options */
// "incremental": true, /* Enable incremental compilation */ // "incremental": true, /* Enable incremental compilation */
"target": "es2022", "target": "es6", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */
"module": "NodeNext", "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */
// "allowJs": true, /* Allow javascript files to be compiled. */ // "allowJs": true, /* Allow javascript files to be compiled. */
// "checkJs": true, /* Report errors in .js files. */ // "checkJs": true, /* Report errors in .js files. */
// "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */
@ -25,7 +25,6 @@
/* Strict Type-Checking Options */ /* Strict Type-Checking Options */
"strict": true, /* Enable all 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. */ "noImplicitAny": false, /* Raise error on expressions and declarations with an implied 'any' type. */
"skipLibCheck": true,
// "strictNullChecks": true, /* Enable strict null checks. */ // "strictNullChecks": true, /* Enable strict null checks. */
// "strictFunctionTypes": true, /* Enable strict checking of function types. */ // "strictFunctionTypes": true, /* Enable strict checking of function types. */
// "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */
@ -45,7 +44,7 @@
// "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ // "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. */ // "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. */ // "typeRoots": [], /* List of folders to include type definitions from. */
"types": ["vitest/globals"], // "types": [], /* Type declaration files to be included in compilation. */
// "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ // "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'. */ "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. */ // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */
@ -61,5 +60,5 @@
// "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */
// "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */
}, },
"exclude": ["node_modules", "**/*.test.ts", "vitest.config.ts"] "exclude": ["node_modules", "**/*.test.ts"]
} }

View file

@ -1,11 +0,0 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'node',
coverage: {
reporter: ['text', 'lcov'],
},
include: ['__tests__/**/*.ts'],
},
});