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'], + }, +});