├── __tests__ ├── release.txt ├── github.test.ts └── util.test.ts ├── .tool-versions ├── tests └── data │ └── foo │ └── bar.txt ├── .github ├── FUNDING.yml ├── release.yml ├── dependabot.yml └── workflows │ └── main.yml ├── demo.png ├── .prettierignore ├── .prettierrc.js ├── vitest.config.ts ├── .gitignore ├── CONTRIBUTING.md ├── LICENSE ├── package.json ├── action.yml ├── src ├── main.ts ├── util.ts └── github.ts ├── tsconfig.json ├── CHANGELOG.md └── README.md /__tests__/release.txt: -------------------------------------------------------------------------------- 1 | bar -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | nodejs 24.11.0 2 | -------------------------------------------------------------------------------- /tests/data/foo/bar.txt: -------------------------------------------------------------------------------- 1 | release me -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | ko_fi: softprops -------------------------------------------------------------------------------- /demo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zendesk/action-gh-release/master/demo.png -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Build outputs 2 | dist/ 3 | lib/ 4 | coverage/ 5 | 6 | # Dependencies 7 | node_modules/ 8 | 9 | # Misc 10 | .github/ 11 | *.log 12 | .DS_Store 13 | __tests__/release.txt 14 | 15 | # Package files 16 | package-lock.json 17 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @type {import('prettier').Config} 3 | */ 4 | module.exports = { 5 | trailingComma: 'all', 6 | tabWidth: 2, 7 | semi: true, 8 | singleQuote: true, 9 | printWidth: 100, 10 | bracketSpacing: true, 11 | }; 12 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config'; 2 | 3 | export default defineConfig({ 4 | test: { 5 | environment: 'node', 6 | coverage: { 7 | reporter: ['text', 'lcov'], 8 | }, 9 | include: ['__tests__/**/*.ts'], 10 | }, 11 | }); 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __tests__/runner/* 2 | # actions requires a node_modules dir https://github.com/actions/toolkit/blob/master/docs/javascript-action.md#publish-a-releasesv1-action 3 | # but its recommended not to check these in https://github.com/actions/toolkit/blob/master/docs/action-versioning.md#recommendations 4 | node_modules 5 | coverage 6 | -------------------------------------------------------------------------------- /.github/release.yml: -------------------------------------------------------------------------------- 1 | changelog: 2 | exclude: 3 | labels: 4 | - ignore-for-release 5 | - github-actions 6 | authors: 7 | - octocat 8 | - renovate[bot] 9 | categories: 10 | - title: Breaking Changes 🛠 11 | labels: 12 | - breaking-change 13 | - title: Exciting New Features 🎉 14 | labels: 15 | - enhancement 16 | - feature 17 | - title: Bug fixes 🐛 18 | labels: 19 | - bug 20 | - title: Other Changes 🔄 21 | labels: 22 | - "*" 23 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: npm 4 | directory: "/" 5 | schedule: 6 | interval: weekly 7 | groups: 8 | npm: 9 | patterns: 10 | - "*" 11 | ignore: 12 | - dependency-name: node-fetch 13 | versions: 14 | - ">=3.0.0" 15 | - dependency-name: "@types/node" 16 | versions: 17 | - ">=22.0.0" 18 | commit-message: 19 | prefix: "chore(deps)" 20 | - package-ecosystem: github-actions 21 | directory: "/" 22 | schedule: 23 | interval: weekly 24 | groups: 25 | github-actions: 26 | patterns: 27 | - "*" 28 | commit-message: 29 | prefix: "chore(deps)" 30 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## bootstrapping 2 | 3 | This a [JavaScript](https://help.github.com/en/articles/about-actions#types-of-actions) action but uses [TypeScript](https://www.typescriptlang.org/docs/home.html) to generate that JavaScript. 4 | 5 | You can bootstrap your environment with a modern version of npm and by running `npm i` at the root of this repo. 6 | 7 | ## testing 8 | 9 | Tests can be found under under `__tests__` directory and are runnable with the `npm t` command. 10 | 11 | ## source code 12 | 13 | Source code can be found under the `src` directory. Running `npm run build` will generate the JavaScript that will run within GitHub workflows. 14 | 15 | ## formatting 16 | 17 | A minimal attempt at keeping a consistent code style is can be applied by running `npm run fmt`. 18 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: main 2 | 3 | on: 4 | push: 5 | pull_request: 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-24.04 10 | steps: 11 | - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5 12 | 13 | - uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6 14 | with: 15 | node-version-file: ".tool-versions" 16 | cache: "npm" 17 | 18 | - name: Install 19 | run: npm ci 20 | - name: Build 21 | run: npm run build 22 | - name: Test 23 | run: npm run test 24 | - name: Format 25 | run: npm run fmtcheck 26 | # - name: "check for uncommitted changes" 27 | # # Ensure no changes, but ignore node_modules dir since dev/fresh ci deps installed. 28 | # run: | 29 | # git diff --exit-code --stat -- . ':!node_modules' \ 30 | # || (echo "##[error] found changed files after build. please 'npm run build && npm run fmt'" \ 31 | # "and check in all changes" \ 32 | # && exit 1) 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2019-current Doug Tangren 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "action-gh-release", 3 | "version": "2.5.0", 4 | "private": true, 5 | "description": "GitHub Action for creating GitHub Releases", 6 | "main": "lib/main.js", 7 | "scripts": { 8 | "build": "ncc build src/main.ts --minify --target es2022", 9 | "build-debug": "ncc build src/main.ts --v8-cache --source-map", 10 | "typecheck": "tsc --noEmit", 11 | "test": "vitest --coverage", 12 | "fmt": "prettier --write \"src/**/*.ts\" \"__tests__/**/*.ts\"", 13 | "fmtcheck": "prettier --check \"src/**/*.ts\" \"__tests__/**/*.ts\"", 14 | "updatetag": "git tag -d v2 && git push origin :v2 && git tag -a v2 -m '' && git push origin v2" 15 | }, 16 | "repository": { 17 | "type": "git", 18 | "url": "git+https://github.com/softprops/action-gh-release.git" 19 | }, 20 | "keywords": [ 21 | "actions" 22 | ], 23 | "author": "softprops", 24 | "dependencies": { 25 | "@actions/core": "^2.0.1", 26 | "@actions/github": "^6.0.1", 27 | "@octokit/plugin-retry": "^8.0.3", 28 | "@octokit/plugin-throttling": "^11.0.3", 29 | "glob": "^13.0.0", 30 | "mime-types": "^3.0.2" 31 | }, 32 | "devDependencies": { 33 | "@types/glob": "^9.0.0", 34 | "@types/mime-types": "^3.0.1", 35 | "@types/node": "^20.19.27", 36 | "@vercel/ncc": "^0.38.4", 37 | "@vitest/coverage-v8": "^4.0.15", 38 | "prettier": "3.7.4", 39 | "ts-node": "^10.9.2", 40 | "typescript": "^5.9.3", 41 | "typescript-formatter": "^7.2.2", 42 | "vitest": "^4.0.4" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /action.yml: -------------------------------------------------------------------------------- 1 | # https://help.github.com/en/articles/metadata-syntax-for-github-actions 2 | name: "GH Release" 3 | description: "Github Action for creating Github Releases" 4 | author: "softprops" 5 | inputs: 6 | body: 7 | description: "Note-worthy description of changes in release" 8 | required: false 9 | body_path: 10 | description: "Path to load note-worthy description of changes in release from" 11 | required: false 12 | name: 13 | description: "Gives the release a custom name. Defaults to tag name" 14 | required: false 15 | tag_name: 16 | description: "Gives a tag name. Defaults to github.ref_name" 17 | required: false 18 | draft: 19 | description: "Creates a draft release. Defaults to false" 20 | required: false 21 | prerelease: 22 | description: "Identify the release as a prerelease. Defaults to false" 23 | required: false 24 | preserve_order: 25 | description: "Preserver the order of the artifacts when uploading" 26 | required: false 27 | files: 28 | description: "Newline-delimited list of path globs for asset files to upload" 29 | required: false 30 | working_directory: 31 | description: "Base directory to resolve 'files' globs against (defaults to job working-directory)" 32 | required: false 33 | overwrite_files: 34 | description: "Overwrite existing files with the same name. Defaults to true" 35 | required: false 36 | default: 'true' 37 | fail_on_unmatched_files: 38 | description: "Fails if any of the `files` globs match nothing. Defaults to false" 39 | required: false 40 | repository: 41 | description: "Repository to make releases against, in / format" 42 | required: false 43 | token: 44 | description: "Authorized secret GitHub Personal Access Token. Defaults to github.token" 45 | required: false 46 | default: ${{ github.token }} 47 | target_commitish: 48 | description: "Commitish value that determines where the Git tag is created from. Can be any branch or commit SHA." 49 | required: false 50 | discussion_category_name: 51 | description: "If specified, a discussion of the specified category is created and linked to the release. The value must be a category that already exists in the repository. If there is already a discussion linked to the release, this parameter is ignored." 52 | required: false 53 | generate_release_notes: 54 | description: "Whether to automatically generate the name and body for this release. If name is specified, the specified name will be used; otherwise, a name will be automatically generated. If body is specified, the body will be pre-pended to the automatically generated notes." 55 | required: false 56 | append_body: 57 | description: "Append to existing body instead of overwriting it. Default is false." 58 | required: false 59 | make_latest: 60 | 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" 61 | required: false 62 | env: 63 | GITHUB_TOKEN: "As provided by Github Actions" 64 | outputs: 65 | url: 66 | description: "URL to the Release HTML Page" 67 | id: 68 | description: "Release ID" 69 | upload_url: 70 | description: "URL for uploading assets to the release" 71 | assets: 72 | description: "JSON array containing information about each uploaded asset, in the format given [here](https://docs.github.com/en/rest/reference/repos#upload-a-release-asset--code-samples) (minus the `uploader` field)" 73 | runs: 74 | using: "node20" 75 | main: "dist/index.js" 76 | branding: 77 | color: "green" 78 | icon: "package" 79 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { setFailed, setOutput } from '@actions/core'; 2 | import { getOctokit } from '@actions/github'; 3 | import { GitHubReleaser, release, finalizeRelease, upload } from './github'; 4 | import { isTag, parseConfig, paths, unmatchedPatterns, uploadUrl } from './util'; 5 | 6 | import { env } from 'process'; 7 | 8 | async function run() { 9 | try { 10 | const config = parseConfig(env); 11 | if (!config.input_tag_name && !isTag(config.github_ref) && !config.input_draft) { 12 | throw new Error(`⚠️ GitHub Releases requires a tag`); 13 | } 14 | if (config.input_files) { 15 | const patterns = unmatchedPatterns(config.input_files, config.input_working_directory); 16 | patterns.forEach((pattern) => { 17 | if (config.input_fail_on_unmatched_files) { 18 | throw new Error(`⚠️ Pattern '${pattern}' does not match any files.`); 19 | } else { 20 | console.warn(`🤔 Pattern '${pattern}' does not match any files.`); 21 | } 22 | }); 23 | if (patterns.length > 0 && config.input_fail_on_unmatched_files) { 24 | throw new Error(`⚠️ There were unmatched files`); 25 | } 26 | } 27 | 28 | // const oktokit = GitHub.plugin( 29 | // require("@octokit/plugin-throttling"), 30 | // require("@octokit/plugin-retry") 31 | // ); 32 | 33 | const gh = getOctokit(config.github_token, { 34 | //new oktokit( 35 | throttle: { 36 | onRateLimit: (retryAfter, options) => { 37 | console.warn(`Request quota exhausted for request ${options.method} ${options.url}`); 38 | if (options.request.retryCount === 0) { 39 | // only retries once 40 | console.log(`Retrying after ${retryAfter} seconds!`); 41 | return true; 42 | } 43 | }, 44 | onAbuseLimit: (retryAfter, options) => { 45 | // does not retry, only logs a warning 46 | console.warn(`Abuse detected for request ${options.method} ${options.url}`); 47 | }, 48 | }, 49 | }); 50 | //); 51 | const releaser = new GitHubReleaser(gh); 52 | let rel = await release(config, releaser); 53 | if (config.input_files && config.input_files.length > 0) { 54 | const files = paths(config.input_files, config.input_working_directory); 55 | if (files.length == 0) { 56 | if (config.input_fail_on_unmatched_files) { 57 | throw new Error(`⚠️ ${config.input_files} does not include a valid file.`); 58 | } else { 59 | console.warn(`🤔 ${config.input_files} does not include a valid file.`); 60 | } 61 | } 62 | const currentAssets = rel.assets; 63 | 64 | const uploadFile = async (path) => { 65 | const json = await upload(config, gh, uploadUrl(rel.upload_url), path, currentAssets); 66 | if (json) { 67 | delete json.uploader; 68 | } 69 | return json; 70 | }; 71 | 72 | let results: (any | null)[]; 73 | if (!config.input_preserve_order) { 74 | results = await Promise.all(files.map(uploadFile)); 75 | } else { 76 | results = []; 77 | for (const path of files) { 78 | results.push(await uploadFile(path)); 79 | } 80 | } 81 | 82 | const assets = results.filter(Boolean); 83 | setOutput('assets', assets); 84 | } 85 | 86 | console.log('Finalizing release...'); 87 | rel = await finalizeRelease(config, releaser, rel); 88 | 89 | console.log(`🎉 Release ready at ${rel.html_url}`); 90 | setOutput('url', rel.html_url); 91 | setOutput('id', rel.id.toString()); 92 | setOutput('upload_url', rel.upload_url); 93 | } catch (error) { 94 | setFailed(error.message); 95 | } 96 | } 97 | 98 | run(); 99 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "useUnknownInCatchVariables": false, 4 | /* Basic Options */ 5 | // "incremental": true, /* Enable incremental compilation */ 6 | "target": "es2022", 7 | "module": "NodeNext", 8 | // "allowJs": true, /* Allow javascript files to be compiled. */ 9 | // "checkJs": true, /* Report errors in .js files. */ 10 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 11 | // "declaration": true, /* Generates corresponding '.d.ts' file. */ 12 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 13 | // "sourceMap": true, /* Generates corresponding '.map' file. */ 14 | // "outFile": "./", /* Concatenate and emit output to single file. */ 15 | "outDir": "./lib", /* Redirect output structure to the directory. */ 16 | "rootDir": "./src", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 17 | // "composite": true, /* Enable project compilation */ 18 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ 19 | // "removeComments": true, /* Do not emit comments to output. */ 20 | // "noEmit": true, /* Do not emit outputs. */ 21 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 22 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 23 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 24 | 25 | /* Strict Type-Checking Options */ 26 | "strict": true, /* Enable all strict type-checking options. */ 27 | "noImplicitAny": false, /* Raise error on expressions and declarations with an implied 'any' type. */ 28 | "skipLibCheck": true, 29 | // "strictNullChecks": true, /* Enable strict null checks. */ 30 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 31 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 32 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 33 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 34 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 35 | 36 | /* Additional Checks */ 37 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 38 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 39 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 40 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 41 | 42 | /* Module Resolution Options */ 43 | // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 44 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 45 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 46 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 47 | // "typeRoots": [], /* List of folders to include type definitions from. */ 48 | "types": ["vitest/globals"], 49 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 50 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 51 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 52 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 53 | 54 | /* Source Map Options */ 55 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 56 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 57 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 58 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 59 | 60 | /* Experimental Options */ 61 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 62 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 63 | }, 64 | "exclude": ["node_modules", "**/*.test.ts", "vitest.config.ts"] 65 | } 66 | -------------------------------------------------------------------------------- /src/util.ts: -------------------------------------------------------------------------------- 1 | import * as glob from 'glob'; 2 | import { statSync, readFileSync } from 'fs'; 3 | import * as pathLib from 'path'; 4 | 5 | export interface Config { 6 | github_token: string; 7 | github_ref: string; 8 | github_repository: string; 9 | // user provided 10 | input_name?: string; 11 | input_tag_name?: string; 12 | input_repository?: string; 13 | input_body?: string; 14 | input_body_path?: string; 15 | input_files?: string[]; 16 | input_working_directory?: string; 17 | input_overwrite_files?: boolean; 18 | input_draft?: boolean; 19 | input_preserve_order?: boolean; 20 | input_prerelease?: boolean; 21 | input_fail_on_unmatched_files?: boolean; 22 | input_target_commitish?: string; 23 | input_discussion_category_name?: string; 24 | input_generate_release_notes?: boolean; 25 | input_append_body?: boolean; 26 | input_make_latest: 'true' | 'false' | 'legacy' | undefined; 27 | } 28 | 29 | export const uploadUrl = (url: string): string => { 30 | const templateMarkerPos = url.indexOf('{'); 31 | if (templateMarkerPos > -1) { 32 | return url.substring(0, templateMarkerPos); 33 | } 34 | return url; 35 | }; 36 | 37 | export const releaseBody = (config: Config): string | undefined => { 38 | if (config.input_body_path) { 39 | try { 40 | const contents = readFileSync(config.input_body_path, 'utf8'); 41 | return contents; 42 | } catch (err: any) { 43 | console.warn( 44 | `⚠️ Failed to read body_path "${config.input_body_path}" (${err?.code ?? 'ERR'}). Falling back to 'body' input.`, 45 | ); 46 | } 47 | } 48 | return config.input_body; 49 | }; 50 | 51 | type Env = { [key: string]: string | undefined }; 52 | 53 | const smartSplit = (input: string): string[] => { 54 | const result: string[] = []; 55 | let current = ''; 56 | let braceDepth = 0; 57 | 58 | for (const ch of input) { 59 | if (ch === '{') { 60 | braceDepth++; 61 | } 62 | if (ch === '}') { 63 | braceDepth--; 64 | } 65 | if (ch === ',' && braceDepth === 0) { 66 | if (current.trim()) { 67 | result.push(current.trim()); 68 | } 69 | current = ''; 70 | } else { 71 | current += ch; 72 | } 73 | } 74 | if (current.trim()) { 75 | result.push(current.trim()); 76 | } 77 | return result; 78 | }; 79 | 80 | export const parseInputFiles = (files: string): string[] => { 81 | return files 82 | .split(/\r?\n/) 83 | .flatMap((line) => smartSplit(line)) 84 | .filter((pat) => pat.trim() !== ''); 85 | }; 86 | 87 | export const parseConfig = (env: Env): Config => { 88 | return { 89 | github_token: env.GITHUB_TOKEN || env.INPUT_TOKEN || '', 90 | github_ref: env.GITHUB_REF || '', 91 | github_repository: env.INPUT_REPOSITORY || env.GITHUB_REPOSITORY || '', 92 | input_name: env.INPUT_NAME, 93 | input_tag_name: env.INPUT_TAG_NAME?.trim(), 94 | input_body: env.INPUT_BODY, 95 | input_body_path: env.INPUT_BODY_PATH, 96 | input_files: parseInputFiles(env.INPUT_FILES || ''), 97 | input_working_directory: env.INPUT_WORKING_DIRECTORY || undefined, 98 | input_overwrite_files: env.INPUT_OVERWRITE_FILES 99 | ? env.INPUT_OVERWRITE_FILES == 'true' 100 | : undefined, 101 | input_draft: env.INPUT_DRAFT ? env.INPUT_DRAFT === 'true' : undefined, 102 | input_preserve_order: env.INPUT_PRESERVE_ORDER ? env.INPUT_PRESERVE_ORDER == 'true' : undefined, 103 | input_prerelease: env.INPUT_PRERELEASE ? env.INPUT_PRERELEASE == 'true' : undefined, 104 | input_fail_on_unmatched_files: env.INPUT_FAIL_ON_UNMATCHED_FILES == 'true', 105 | input_target_commitish: env.INPUT_TARGET_COMMITISH || undefined, 106 | input_discussion_category_name: env.INPUT_DISCUSSION_CATEGORY_NAME || undefined, 107 | input_generate_release_notes: env.INPUT_GENERATE_RELEASE_NOTES == 'true', 108 | input_append_body: env.INPUT_APPEND_BODY == 'true', 109 | input_make_latest: parseMakeLatest(env.INPUT_MAKE_LATEST), 110 | }; 111 | }; 112 | 113 | const parseMakeLatest = (value: string | undefined): 'true' | 'false' | 'legacy' | undefined => { 114 | if (value === 'true' || value === 'false' || value === 'legacy') { 115 | return value; 116 | } 117 | return undefined; 118 | }; 119 | 120 | export const paths = (patterns: string[], cwd?: string): string[] => { 121 | return patterns.reduce((acc: string[], pattern: string): string[] => { 122 | const matches = glob.sync(pattern, { cwd, dot: true, absolute: false }); 123 | const resolved = matches 124 | .map((p) => (cwd ? pathLib.join(cwd, p) : p)) 125 | .filter((p) => { 126 | try { 127 | return statSync(p).isFile(); 128 | } catch { 129 | return false; 130 | } 131 | }); 132 | return acc.concat(resolved); 133 | }, []); 134 | }; 135 | 136 | export const unmatchedPatterns = (patterns: string[], cwd?: string): string[] => { 137 | return patterns.reduce((acc: string[], pattern: string): string[] => { 138 | const matches = glob.sync(pattern, { cwd, dot: true, absolute: false }); 139 | const files = matches.filter((p) => { 140 | try { 141 | const full = cwd ? pathLib.join(cwd, p) : p; 142 | return statSync(full).isFile(); 143 | } catch { 144 | return false; 145 | } 146 | }); 147 | return acc.concat(files.length == 0 ? [pattern] : []); 148 | }, []); 149 | }; 150 | 151 | export const isTag = (ref: string): boolean => { 152 | return ref.startsWith('refs/tags/'); 153 | }; 154 | 155 | export const alignAssetName = (assetName: string): string => { 156 | return assetName.replace(/ /g, '.'); 157 | }; 158 | -------------------------------------------------------------------------------- /__tests__/github.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | asset, 3 | findTagFromReleases, 4 | mimeOrDefault, 5 | release, 6 | Release, 7 | Releaser, 8 | } from '../src/github'; 9 | 10 | import { assert, describe, it } from 'vitest'; 11 | 12 | describe('github', () => { 13 | describe('mimeOrDefault', () => { 14 | it('returns a specific mime for common path', async () => { 15 | assert.equal(mimeOrDefault('foo.tar.gz'), 'application/gzip'); 16 | }); 17 | it('returns default mime for uncommon path', async () => { 18 | assert.equal(mimeOrDefault('foo.uncommon'), 'application/octet-stream'); 19 | }); 20 | }); 21 | 22 | describe('asset', () => { 23 | it('derives asset info from a path', async () => { 24 | const { name, mime, size } = asset('tests/data/foo/bar.txt'); 25 | assert.equal(name, 'bar.txt'); 26 | assert.equal(mime, 'text/plain'); 27 | assert.equal(size, 10); 28 | }); 29 | }); 30 | 31 | describe('findTagFromReleases', () => { 32 | const owner = 'owner'; 33 | const repo = 'repo'; 34 | 35 | const mockRelease: Release = { 36 | id: 1, 37 | upload_url: `https://api.github.com/repos/${owner}/${repo}/releases/1/assets`, 38 | html_url: `https://github.com/${owner}/${repo}/releases/tag/v1.0.0`, 39 | tag_name: 'v1.0.0', 40 | name: 'Test Release', 41 | body: 'Test body', 42 | target_commitish: 'main', 43 | draft: false, 44 | prerelease: false, 45 | assets: [], 46 | } as const; 47 | 48 | const mockReleaser: Releaser = { 49 | getReleaseByTag: () => Promise.reject('Not implemented'), 50 | createRelease: () => Promise.reject('Not implemented'), 51 | updateRelease: () => Promise.reject('Not implemented'), 52 | finalizeRelease: () => Promise.reject('Not implemented'), 53 | allReleases: async function* () { 54 | yield { data: [mockRelease] }; 55 | }, 56 | } as const; 57 | 58 | describe('when the tag_name is not an empty string', () => { 59 | const targetTag = 'v1.0.0'; 60 | 61 | it('finds a matching release in first batch of results', async () => { 62 | const targetRelease = { 63 | ...mockRelease, 64 | owner, 65 | repo, 66 | tag_name: targetTag, 67 | }; 68 | const otherRelease = { 69 | ...mockRelease, 70 | owner, 71 | repo, 72 | tag_name: 'v1.0.1', 73 | }; 74 | 75 | const releaser = { 76 | ...mockReleaser, 77 | allReleases: async function* () { 78 | yield { data: [targetRelease] }; 79 | yield { data: [otherRelease] }; 80 | }, 81 | }; 82 | 83 | const result = await findTagFromReleases(releaser, owner, repo, targetTag); 84 | 85 | assert.deepStrictEqual(result, targetRelease); 86 | }); 87 | 88 | it('finds a matching release in second batch of results', async () => { 89 | const targetRelease = { 90 | ...mockRelease, 91 | owner, 92 | repo, 93 | tag_name: targetTag, 94 | }; 95 | const otherRelease = { 96 | ...mockRelease, 97 | owner, 98 | repo, 99 | tag_name: 'v1.0.1', 100 | }; 101 | 102 | const releaser = { 103 | ...mockReleaser, 104 | allReleases: async function* () { 105 | yield { data: [otherRelease] }; 106 | yield { data: [targetRelease] }; 107 | }, 108 | }; 109 | 110 | const result = await findTagFromReleases(releaser, owner, repo, targetTag); 111 | assert.deepStrictEqual(result, targetRelease); 112 | }); 113 | 114 | it('returns undefined when a release is not found in any batch', async () => { 115 | const otherRelease = { 116 | ...mockRelease, 117 | owner, 118 | repo, 119 | tag_name: 'v1.0.1', 120 | }; 121 | const releaser = { 122 | ...mockReleaser, 123 | allReleases: async function* () { 124 | yield { data: [otherRelease] }; 125 | yield { data: [otherRelease] }; 126 | }, 127 | }; 128 | 129 | const result = await findTagFromReleases(releaser, owner, repo, targetTag); 130 | 131 | assert.strictEqual(result, undefined); 132 | }); 133 | 134 | it('returns undefined when no releases are returned', async () => { 135 | const releaser = { 136 | ...mockReleaser, 137 | allReleases: async function* () { 138 | yield { data: [] }; 139 | }, 140 | }; 141 | 142 | const result = await findTagFromReleases(releaser, owner, repo, targetTag); 143 | 144 | assert.strictEqual(result, undefined); 145 | }); 146 | }); 147 | 148 | describe('when the tag_name is an empty string', () => { 149 | const emptyTag = ''; 150 | 151 | it('finds a matching release in first batch of results', async () => { 152 | const targetRelease = { 153 | ...mockRelease, 154 | owner, 155 | repo, 156 | tag_name: emptyTag, 157 | }; 158 | const otherRelease = { 159 | ...mockRelease, 160 | owner, 161 | repo, 162 | tag_name: 'v1.0.1', 163 | }; 164 | 165 | const releaser = { 166 | ...mockReleaser, 167 | allReleases: async function* () { 168 | yield { data: [targetRelease] }; 169 | yield { data: [otherRelease] }; 170 | }, 171 | }; 172 | 173 | const result = await findTagFromReleases(releaser, owner, repo, emptyTag); 174 | 175 | assert.deepStrictEqual(result, targetRelease); 176 | }); 177 | 178 | it('finds a matching release in second batch of results', async () => { 179 | const targetRelease = { 180 | ...mockRelease, 181 | owner, 182 | repo, 183 | tag_name: emptyTag, 184 | }; 185 | const otherRelease = { 186 | ...mockRelease, 187 | owner, 188 | repo, 189 | tag_name: 'v1.0.1', 190 | }; 191 | 192 | const releaser = { 193 | ...mockReleaser, 194 | allReleases: async function* () { 195 | yield { data: [otherRelease] }; 196 | yield { data: [targetRelease] }; 197 | }, 198 | }; 199 | 200 | const result = await findTagFromReleases(releaser, owner, repo, emptyTag); 201 | assert.deepStrictEqual(result, targetRelease); 202 | }); 203 | 204 | it('returns undefined when a release is not found in any batch', async () => { 205 | const otherRelease = { 206 | ...mockRelease, 207 | owner, 208 | repo, 209 | tag_name: 'v1.0.1', 210 | }; 211 | const releaser = { 212 | ...mockReleaser, 213 | allReleases: async function* () { 214 | yield { data: [otherRelease] }; 215 | yield { data: [otherRelease] }; 216 | }, 217 | }; 218 | 219 | const result = await findTagFromReleases(releaser, owner, repo, emptyTag); 220 | 221 | assert.strictEqual(result, undefined); 222 | }); 223 | 224 | it('returns undefined when no releases are returned', async () => { 225 | const releaser = { 226 | ...mockReleaser, 227 | allReleases: async function* () { 228 | yield { data: [] }; 229 | }, 230 | }; 231 | 232 | const result = await findTagFromReleases(releaser, owner, repo, emptyTag); 233 | 234 | assert.strictEqual(result, undefined); 235 | }); 236 | }); 237 | }); 238 | 239 | describe('error handling', () => { 240 | it('handles 422 already_exists error gracefully', async () => { 241 | const mockReleaser: Releaser = { 242 | getReleaseByTag: () => Promise.reject('Not implemented'), 243 | createRelease: () => 244 | Promise.reject({ 245 | status: 422, 246 | response: { data: { errors: [{ code: 'already_exists' }] } }, 247 | }), 248 | updateRelease: () => 249 | Promise.resolve({ 250 | data: { 251 | id: 1, 252 | upload_url: 'test', 253 | html_url: 'test', 254 | tag_name: 'v1.0.0', 255 | name: 'test', 256 | body: 'test', 257 | target_commitish: 'main', 258 | draft: true, 259 | prerelease: false, 260 | assets: [], 261 | }, 262 | }), 263 | finalizeRelease: async () => {}, 264 | allReleases: async function* () { 265 | yield { 266 | data: [ 267 | { 268 | id: 1, 269 | upload_url: 'test', 270 | html_url: 'test', 271 | tag_name: 'v1.0.0', 272 | name: 'test', 273 | body: 'test', 274 | target_commitish: 'main', 275 | draft: false, 276 | prerelease: false, 277 | assets: [], 278 | }, 279 | ], 280 | }; 281 | }, 282 | } as const; 283 | 284 | const config = { 285 | github_token: 'test-token', 286 | github_ref: 'refs/tags/v1.0.0', 287 | github_repository: 'owner/repo', 288 | input_tag_name: undefined, 289 | input_name: undefined, 290 | input_body: undefined, 291 | input_body_path: undefined, 292 | input_files: [], 293 | input_draft: undefined, 294 | input_prerelease: undefined, 295 | input_preserve_order: undefined, 296 | input_overwrite_files: undefined, 297 | input_fail_on_unmatched_files: false, 298 | input_target_commitish: undefined, 299 | input_discussion_category_name: undefined, 300 | input_generate_release_notes: false, 301 | input_append_body: false, 302 | input_make_latest: undefined, 303 | }; 304 | 305 | const result = await release(config, mockReleaser, 1); 306 | assert.ok(result); 307 | assert.equal(result.id, 1); 308 | }); 309 | }); 310 | }); 311 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 2.5.0 2 | 3 | ## What's Changed 4 | 5 | ### Exciting New Features 🎉 6 | 7 | * feat: mark release as draft until all artifacts are uploaded by @dumbmoron in https://github.com/softprops/action-gh-release/pull/692 8 | 9 | ### Other Changes 🔄 10 | 11 | * dependency updates 12 | 13 | ## 2.4.2 14 | 15 | ## What's Changed 16 | 17 | ### Exciting New Features 🎉 18 | 19 | * feat: Ensure generated release notes cannot be over 125000 characters by @BeryJu in https://github.com/softprops/action-gh-release/pull/684 20 | 21 | ### Other Changes 🔄 22 | 23 | * dependency updates 24 | 25 | ## 2.4.1 26 | 27 | ## What's Changed 28 | 29 | ### Other Changes 🔄 30 | 31 | * fix(util): support brace expansion globs containing commas in parseInputFiles by @Copilot in https://github.com/softprops/action-gh-release/pull/672 32 | * fix: gracefully fallback to body when body_path cannot be read by @Copilot in https://github.com/softprops/action-gh-release/pull/671 33 | 34 | ## 2.4.0 35 | 36 | ## What's Changed 37 | 38 | ### Exciting New Features 🎉 39 | 40 | * feat(action): respect working_directory for files globs by @stephenway in https://github.com/softprops/action-gh-release/pull/667 41 | 42 | ## 2.3.4 43 | 44 | ## What's Changed 45 | 46 | ### Bug fixes 🐛 47 | 48 | * fix(action): handle 422 already_exists race condition by @stephenway in https://github.com/softprops/action-gh-release/pull/665 49 | 50 | ### Other Changes 🔄 51 | 52 | - dependency updates 53 | 54 | ## 2.3.3 55 | 56 | ## What's Changed 57 | 58 | ### Exciting New Features 🎉 59 | 60 | * feat: add input option `overwrite_files` by @asfernandes in https://github.com/softprops/action-gh-release/pull/343 61 | 62 | ### Other Changes 🔄 63 | 64 | - dependency updates 65 | 66 | ## 2.3.2 67 | 68 | * fix: revert fs `readableWebStream` change 69 | 70 | ## 2.3.1 71 | 72 | ### Bug fixes 🐛 73 | 74 | * fix: fix file closing issue by @WailGree in https://github.com/softprops/action-gh-release/pull/629 75 | 76 | ## 2.3.0 77 | 78 | * Migrate from jest to vitest 79 | * Replace `mime` with `mime-types` 80 | * Bump to use node 24 81 | * Dependency updates 82 | 83 | ## 2.2.2 84 | 85 | ## What's Changed 86 | 87 | ### Bug fixes 🐛 88 | 89 | * fix: updating release draft status from true to false by @galargh in https://github.com/softprops/action-gh-release/pull/316 90 | 91 | ### Other Changes 🔄 92 | 93 | * chore: simplify ref_type test by @steinybot in https://github.com/softprops/action-gh-release/pull/598 94 | * fix(docs): clarify the default for tag_name by @muzimuzhi in https://github.com/softprops/action-gh-release/pull/599 95 | * test(release): add unit tests when searching for a release by @rwaskiewicz in https://github.com/softprops/action-gh-release/pull/603 96 | * dependency updates 97 | 98 | ## 2.2.1 99 | 100 | ## What's Changed 101 | 102 | ### Bug fixes 🐛 103 | 104 | * fix: big file uploads by @xen0n in https://github.com/softprops/action-gh-release/pull/562 105 | 106 | ### Other Changes 🔄 107 | * 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 108 | * 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 109 | * 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 110 | 111 | ## 2.2.0 112 | 113 | ## What's Changed 114 | 115 | ### Exciting New Features 🎉 116 | 117 | * feat: read the release assets asynchronously by @xen0n in https://github.com/softprops/action-gh-release/pull/552 118 | 119 | ### Bug fixes 🐛 120 | 121 | * fix(docs): clarify the default for tag_name by @alexeagle in https://github.com/softprops/action-gh-release/pull/544 122 | 123 | ### Other Changes 🔄 124 | 125 | * chore(deps): bump typescript from 5.6.3 to 5.7.2 by @dependabot in https://github.com/softprops/action-gh-release/pull/548 126 | * 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 127 | * 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 128 | * 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 129 | * chore(deps): bump prettier from 3.3.3 to 3.4.1 by @dependabot in https://github.com/softprops/action-gh-release/pull/550 130 | * 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 131 | * chore(deps): bump prettier from 3.4.1 to 3.4.2 by @dependabot in https://github.com/softprops/action-gh-release/pull/554 132 | 133 | ## 2.1.0 134 | 135 | ## What's Changed 136 | 137 | ### Exciting New Features 🎉 138 | * feat: add support for release assets with multiple spaces within the name by @dukhine in https://github.com/softprops/action-gh-release/pull/518 139 | * feat: preserve upload order by @richarddd in https://github.com/softprops/action-gh-release/pull/500 140 | 141 | ### Other Changes 🔄 142 | * 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 143 | 144 | ## 2.0.9 145 | 146 | - maintenance release with updated dependencies 147 | 148 | ## 2.0.8 149 | 150 | ### Other Changes 🔄 151 | * chore(deps): bump prettier from 2.8.0 to 3.3.3 by @dependabot in https://github.com/softprops/action-gh-release/pull/480 152 | * 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 153 | * 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 154 | * chore(deps): bump glob from 10.4.2 to 11.0.0 by @dependabot in https://github.com/softprops/action-gh-release/pull/477 155 | * refactor: write jest config in ts by @chenrui333 in https://github.com/softprops/action-gh-release/pull/485 156 | * 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 157 | 158 | ## 2.0.7 159 | 160 | ### Bug fixes 🐛 161 | 162 | * Fix missing update release body by @FirelightFlagboy in https://github.com/softprops/action-gh-release/pull/365 163 | 164 | ### Other Changes 🔄 165 | 166 | * Bump @octokit/plugin-retry from 4.0.3 to 7.1.1 by @dependabot in https://github.com/softprops/action-gh-release/pull/443 167 | * Bump typescript from 4.9.5 to 5.5.2 by @dependabot in https://github.com/softprops/action-gh-release/pull/467 168 | * Bump @types/node from 20.14.6 to 20.14.8 by @dependabot in https://github.com/softprops/action-gh-release/pull/469 169 | * Bump @types/node from 20.14.8 to 20.14.9 by @dependabot in https://github.com/softprops/action-gh-release/pull/473 170 | * Bump typescript from 5.5.2 to 5.5.3 by @dependabot in https://github.com/softprops/action-gh-release/pull/472 171 | * Bump ts-jest from 29.1.5 to 29.2.2 by @dependabot in https://github.com/softprops/action-gh-release/pull/479 172 | * docs: document that existing releases are updated by @jvanbruegge in https://github.com/softprops/action-gh-release/pull/474 173 | 174 | ## 2.0.6 175 | 176 | - maintenance release with updated dependencies 177 | 178 | ## 2.0.5 179 | 180 | - 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) 181 | - Improvements to error handling [#449](https://github.com/softprops/action-gh-release/pull/449) via [@till](https://github.com/till) 182 | 183 | ## 2.0.4 184 | 185 | - Minor follow up to [#417](https://github.com/softprops/action-gh-release/pull/417). [#425](https://github.com/softprops/action-gh-release/pull/425) 186 | 187 | ## 2.0.3 188 | 189 | - Declare `make_latest` as an input field in `action.yml` [#419](https://github.com/softprops/action-gh-release/pull/419) 190 | 191 | ## 2.0.2 192 | 193 | - Revisit approach to [#384](https://github.com/softprops/action-gh-release/pull/384) making unresolved pattern failures opt-in [#417](https://github.com/softprops/action-gh-release/pull/417) 194 | 195 | ## 2.0.1 196 | 197 | - Add support for make_latest property [#304](https://github.com/softprops/action-gh-release/pull/304) via [@samueljseay](https://github.com/samueljseay) 198 | - Fail run if files setting contains invalid patterns [#384](https://github.com/softprops/action-gh-release/pull/384) via [@rpdelaney](https://github.com/rpdelaney) 199 | - Add support for proxy env variables (don't use node-fetch) [#386](https://github.com/softprops/action-gh-release/pull/386/) via [@timor-raiman](https://github.com/timor-raiman) 200 | - Suppress confusing warning when input_files is empty [#389](https://github.com/softprops/action-gh-release/pull/389) via [@Drowze](https://github.com/Drowze) 201 | 202 | ## 2.0.0 203 | 204 | - `2.0.0`!? this release corrects a disjunction between git tag versions used in the marketplace and the versions listed in this file. Previous versions should have really been 1.\*. Going forward this should be better aligned. 205 | - Upgrade action.yml declaration to node20 to address deprecations 206 | 207 | ## 0.1.15 208 | 209 | - Upgrade to action.yml declaration to node16 to address deprecations 210 | - Upgrade dependencies 211 | - Add `asset` output as a JSON array containing information about the uploaded assets 212 | 213 | ## 0.1.14 214 | 215 | - provides a new workflow input option `generate_release_notes` which when set to true will automatically generate release notes for you based on GitHub activity [#179](https://github.com/softprops/action-gh-release/pull/179). Please see the [GitHub docs for this feature](https://docs.github.com/en/repositories/releasing-projects-on-github/automatically-generated-release-notes) for more information 216 | 217 | ## 0.1.13 218 | 219 | - fix issue with multiple runs concatenating release bodies [#145](https://github.com/softprops/action-gh-release/pull/145) 220 | 221 | ## 0.1.12 222 | 223 | - fix bug leading to empty strings substituted for inputs users don't provide breaking api calls [#144](https://github.com/softprops/action-gh-release/pull/144) 224 | 225 | ## 0.1.11 226 | 227 | - better error message on release create failed [#143](https://github.com/softprops/action-gh-release/pull/143) 228 | 229 | ## 0.1.10 230 | 231 | - fixed error message formatting for file uploads 232 | 233 | ## 0.1.9 234 | 235 | - add support for linking release to GitHub discussion [#136](https://github.com/softprops/action-gh-release/pull/136) 236 | 237 | ## 0.1.8 238 | 239 | - address recent warnings in assert upload api as well as introduce asset upload overrides, allowing for multiple runs for the same release with the same named asserts [#134](https://github.com/softprops/action-gh-release/pull/134) 240 | - fix backwards compatibility with `GITHUB_TOKEN` resolution. `GITHUB_TOKEN` is now resolved first from an env variable and then from an input [#133](https://github.com/softprops/action-gh-release/pull/133) 241 | - trim white space in provided `tag_name` [#130](https://github.com/softprops/action-gh-release/pull/130) 242 | 243 | ## 0.1.7 244 | 245 | - allow creating draft releases without a tag [#95](https://github.com/softprops/action-gh-release/pull/95) 246 | - Set default token for simpler setup [#83](https://github.com/softprops/action-gh-release/pull/83) 247 | - fix regression with action yml [#126](https://github.com/softprops/action-gh-release/pull/126) 248 | 249 | ## 0.1.6 250 | 251 | This is a release catch up have a hiatus. Future releases will happen more frequently 252 | 253 | - Add 'fail_on_unmatched_files' input, useful for catching cases where your `files` input does not actually match what you expect [#55](https://github.com/softprops/action-gh-release/pull/55) 254 | - Add `repository` input, useful for creating a release in an external repository [#61](https://github.com/softprops/action-gh-release/pull/61) 255 | - Add release `id` to outputs, useful for referring to release in workflow steps following the step that uses this action [#60](https://github.com/softprops/action-gh-release/pull/60) 256 | - Add `upload_url` as action output, useful for managing uploads separately [#75](https://github.com/softprops/action-gh-release/pull/75) 257 | - Support custom `target_commitish` value, useful to customize the default [#76](https://github.com/softprops/action-gh-release/pull/76) 258 | - fix `body_path` input first then fall back on `body` input. This was the originally documented precedence but was implemented in the opposite order! [#85](https://github.com/softprops/action-gh-release/pull/85) 259 | - Retain original release info if the keys are not set, useful for filling in blanks for a release you've already started separately [#109](https://github.com/softprops/action-gh-release/pull/109) 260 | - Limit number of times github api request to create a release is retried, useful for avoiding eating up your rate limit and action minutes due to either an invalid token or other circumstance causing the api call to fail [#111](https://github.com/softprops/action-gh-release/pull/111) 261 | 262 | ## 0.1.5 263 | 264 | - Added support for specifying tag name [#39](https://github.com/softprops/action-gh-release/pull/39) 265 | 266 | ## 0.1.4 267 | 268 | - Added support for updating releases body [#36](https://github.com/softprops/action-gh-release/pull/36) 269 | - Steps can now access the url of releases with the `url` output of this Action [#28](https://github.com/softprops/action-gh-release/pull/28) 270 | - Added basic GitHub API retry support to manage API turbulence [#26](https://github.com/softprops/action-gh-release/pull/26) 271 | 272 | ## 0.1.3 273 | 274 | - Fixed where `with: body_path` was not being used in generated GitHub releases 275 | 276 | ## 0.1.2 277 | 278 | - Add support for merging draft releases [#16](https://github.com/softprops/action-gh-release/pull/16) 279 | 280 | GitHub's api doesn't explicitly have a way of fetching a draft release by tag name which caused draft releases to appear as separate releases when used in a build matrix. 281 | This is now fixed. 282 | 283 | - Add support for newline-delimited asset list [#18](https://github.com/softprops/action-gh-release/pull/18) 284 | 285 | GitHub actions inputs don't inherently support lists of things and one might like to append a list of files to include in a release. Previously this was possible using a comma-delimited list of asset path patterns to upload. You can now provide these as a newline delimited list for better readability 286 | 287 | ```yaml 288 | - name: Release 289 | uses: softprops/action-gh-release@v1 290 | if: startsWith(github.ref, 'refs/tags/') 291 | with: 292 | files: | 293 | filea.txt 294 | fileb.txt 295 | filec.txt 296 | env: 297 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 298 | ``` 299 | 300 | - Add support for prerelease annotated GitHub releases with the new input field `with.prerelease: true` [#19](https://github.com/softprops/action-gh-release/pull/19) 301 | 302 | --- 303 | 304 | ## 0.1.1 305 | 306 | - Add support for publishing releases on all supported virtual hosts 307 | 308 | You'll need to remove `docker://` prefix and use the `@v1` action tag 309 | 310 | --- 311 | 312 | ## 0.1.0 313 | 314 | - Initial release 315 | -------------------------------------------------------------------------------- /src/github.ts: -------------------------------------------------------------------------------- 1 | import { GitHub } from '@actions/github/lib/utils'; 2 | import { statSync } from 'fs'; 3 | import { open } from 'fs/promises'; 4 | import { lookup } from 'mime-types'; 5 | import { basename } from 'path'; 6 | import { alignAssetName, Config, isTag, releaseBody } from './util'; 7 | 8 | type GitHub = InstanceType; 9 | 10 | export interface ReleaseAsset { 11 | name: string; 12 | mime: string; 13 | size: number; 14 | } 15 | 16 | export interface Release { 17 | id: number; 18 | upload_url: string; 19 | html_url: string; 20 | tag_name: string; 21 | name: string | null; 22 | body?: string | null | undefined; 23 | target_commitish: string; 24 | draft: boolean; 25 | prerelease: boolean; 26 | assets: Array<{ id: number; name: string }>; 27 | } 28 | 29 | export interface Releaser { 30 | getReleaseByTag(params: { owner: string; repo: string; tag: string }): Promise<{ data: Release }>; 31 | 32 | createRelease(params: { 33 | owner: string; 34 | repo: string; 35 | tag_name: string; 36 | name: string; 37 | body: string | undefined; 38 | draft: boolean | undefined; 39 | prerelease: boolean | undefined; 40 | target_commitish: string | undefined; 41 | discussion_category_name: string | undefined; 42 | generate_release_notes: boolean | undefined; 43 | make_latest: 'true' | 'false' | 'legacy' | undefined; 44 | }): Promise<{ data: Release }>; 45 | 46 | updateRelease(params: { 47 | owner: string; 48 | repo: string; 49 | release_id: number; 50 | tag_name: string; 51 | target_commitish: string; 52 | name: string; 53 | body: string | undefined; 54 | draft: boolean | undefined; 55 | prerelease: boolean | undefined; 56 | discussion_category_name: string | undefined; 57 | generate_release_notes: boolean | undefined; 58 | make_latest: 'true' | 'false' | 'legacy' | undefined; 59 | }): Promise<{ data: Release }>; 60 | 61 | finalizeRelease(params: { 62 | owner: string; 63 | repo: string; 64 | release_id: number; 65 | }): Promise<{ data: Release }>; 66 | 67 | allReleases(params: { owner: string; repo: string }): AsyncIterableIterator<{ data: Release[] }>; 68 | } 69 | 70 | export class GitHubReleaser implements Releaser { 71 | github: GitHub; 72 | constructor(github: GitHub) { 73 | this.github = github; 74 | } 75 | 76 | getReleaseByTag(params: { 77 | owner: string; 78 | repo: string; 79 | tag: string; 80 | }): Promise<{ data: Release }> { 81 | return this.github.rest.repos.getReleaseByTag(params); 82 | } 83 | 84 | async getReleaseNotes(params: { 85 | owner: string; 86 | repo: string; 87 | tag_name: string; 88 | target_commitish: string | undefined; 89 | }): Promise<{ 90 | data: { 91 | name: string; 92 | body: string; 93 | }; 94 | }> { 95 | return await this.github.rest.repos.generateReleaseNotes(params); 96 | } 97 | 98 | truncateReleaseNotes(input: string): string { 99 | // release notes can be a maximum of 125000 characters 100 | const githubNotesMaxCharLength = 125000; 101 | return input.substring(0, githubNotesMaxCharLength - 1); 102 | } 103 | 104 | async createRelease(params: { 105 | owner: string; 106 | repo: string; 107 | tag_name: string; 108 | name: string; 109 | body: string | undefined; 110 | draft: boolean | undefined; 111 | prerelease: boolean | undefined; 112 | target_commitish: string | undefined; 113 | discussion_category_name: string | undefined; 114 | generate_release_notes: boolean | undefined; 115 | make_latest: 'true' | 'false' | 'legacy' | undefined; 116 | }): Promise<{ data: Release }> { 117 | if ( 118 | typeof params.make_latest === 'string' && 119 | !['true', 'false', 'legacy'].includes(params.make_latest) 120 | ) { 121 | params.make_latest = undefined; 122 | } 123 | if (params.generate_release_notes) { 124 | const releaseNotes = await this.getReleaseNotes(params); 125 | params.generate_release_notes = false; 126 | if (params.body) { 127 | params.body = `${params.body}\n\n${releaseNotes.data.body}`; 128 | } else { 129 | params.body = releaseNotes.data.body; 130 | } 131 | } 132 | params.body = params.body ? this.truncateReleaseNotes(params.body) : undefined; 133 | return this.github.rest.repos.createRelease(params); 134 | } 135 | 136 | async updateRelease(params: { 137 | owner: string; 138 | repo: string; 139 | release_id: number; 140 | tag_name: string; 141 | target_commitish: string; 142 | name: string; 143 | body: string | undefined; 144 | draft: boolean | undefined; 145 | prerelease: boolean | undefined; 146 | discussion_category_name: string | undefined; 147 | generate_release_notes: boolean | undefined; 148 | make_latest: 'true' | 'false' | 'legacy' | undefined; 149 | }): Promise<{ data: Release }> { 150 | if ( 151 | typeof params.make_latest === 'string' && 152 | !['true', 'false', 'legacy'].includes(params.make_latest) 153 | ) { 154 | params.make_latest = undefined; 155 | } 156 | if (params.generate_release_notes) { 157 | const releaseNotes = await this.getReleaseNotes(params); 158 | params.generate_release_notes = false; 159 | if (params.body) { 160 | params.body = `${params.body}\n\n${releaseNotes.data.body}`; 161 | } else { 162 | params.body = releaseNotes.data.body; 163 | } 164 | } 165 | params.body = params.body ? this.truncateReleaseNotes(params.body) : undefined; 166 | return this.github.rest.repos.updateRelease(params); 167 | } 168 | 169 | async finalizeRelease(params: { owner: string; repo: string; release_id: number }) { 170 | return await this.github.rest.repos.updateRelease({ 171 | owner: params.owner, 172 | repo: params.repo, 173 | release_id: params.release_id, 174 | draft: false, 175 | }); 176 | } 177 | 178 | allReleases(params: { owner: string; repo: string }): AsyncIterableIterator<{ data: Release[] }> { 179 | const updatedParams = { per_page: 100, ...params }; 180 | return this.github.paginate.iterator( 181 | this.github.rest.repos.listReleases.endpoint.merge(updatedParams), 182 | ); 183 | } 184 | } 185 | 186 | export const asset = (path: string): ReleaseAsset => { 187 | return { 188 | name: basename(path), 189 | mime: mimeOrDefault(path), 190 | size: statSync(path).size, 191 | }; 192 | }; 193 | 194 | export const mimeOrDefault = (path: string): string => { 195 | return lookup(path) || 'application/octet-stream'; 196 | }; 197 | 198 | export const upload = async ( 199 | config: Config, 200 | github: GitHub, 201 | url: string, 202 | path: string, 203 | currentAssets: Array<{ id: number; name: string }>, 204 | ): Promise => { 205 | const [owner, repo] = config.github_repository.split('/'); 206 | const { name, mime, size } = asset(path); 207 | const currentAsset = currentAssets.find( 208 | // 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. 209 | // 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 210 | // see https://docs.github.com/en/rest/releases/assets?apiVersion=2022-11-28#upload-a-release-asset 211 | ({ name: currentName }) => currentName == alignAssetName(name), 212 | ); 213 | if (currentAsset) { 214 | if (config.input_overwrite_files === false) { 215 | console.log(`Asset ${name} already exists and overwrite_files is false...`); 216 | return null; 217 | } else { 218 | console.log(`♻️ Deleting previously uploaded asset ${name}...`); 219 | await github.rest.repos.deleteReleaseAsset({ 220 | asset_id: currentAsset.id || 1, 221 | owner, 222 | repo, 223 | }); 224 | } 225 | } 226 | console.log(`⬆️ Uploading ${name}...`); 227 | const endpoint = new URL(url); 228 | endpoint.searchParams.append('name', name); 229 | const fh = await open(path); 230 | try { 231 | const resp = await github.request({ 232 | method: 'POST', 233 | url: endpoint.toString(), 234 | headers: { 235 | 'content-length': `${size}`, 236 | 'content-type': mime, 237 | authorization: `token ${config.github_token}`, 238 | }, 239 | data: fh.readableWebStream({ type: 'bytes' }), 240 | }); 241 | const json = resp.data; 242 | if (resp.status !== 201) { 243 | throw new Error( 244 | `Failed to upload release asset ${name}. received status code ${ 245 | resp.status 246 | }\n${json.message}\n${JSON.stringify(json.errors)}`, 247 | ); 248 | } 249 | console.log(`✅ Uploaded ${name}`); 250 | return json; 251 | } finally { 252 | await fh.close(); 253 | } 254 | }; 255 | 256 | export const release = async ( 257 | config: Config, 258 | releaser: Releaser, 259 | maxRetries: number = 3, 260 | ): Promise => { 261 | if (maxRetries <= 0) { 262 | console.log(`❌ Too many retries. Aborting...`); 263 | throw new Error('Too many retries.'); 264 | } 265 | 266 | const [owner, repo] = config.github_repository.split('/'); 267 | const tag = 268 | config.input_tag_name || 269 | (isTag(config.github_ref) ? config.github_ref.replace('refs/tags/', '') : ''); 270 | 271 | const discussion_category_name = config.input_discussion_category_name; 272 | const generate_release_notes = config.input_generate_release_notes; 273 | try { 274 | const _release: Release | undefined = await findTagFromReleases(releaser, owner, repo, tag); 275 | 276 | if (_release === undefined) { 277 | return await createRelease( 278 | tag, 279 | config, 280 | releaser, 281 | owner, 282 | repo, 283 | discussion_category_name, 284 | generate_release_notes, 285 | maxRetries, 286 | ); 287 | } 288 | 289 | let existingRelease: Release = _release!; 290 | console.log(`Found release ${existingRelease.name} (with id=${existingRelease.id})`); 291 | 292 | const release_id = existingRelease.id; 293 | let target_commitish: string; 294 | if ( 295 | config.input_target_commitish && 296 | config.input_target_commitish !== existingRelease.target_commitish 297 | ) { 298 | console.log( 299 | `Updating commit from "${existingRelease.target_commitish}" to "${config.input_target_commitish}"`, 300 | ); 301 | target_commitish = config.input_target_commitish; 302 | } else { 303 | target_commitish = existingRelease.target_commitish; 304 | } 305 | 306 | const tag_name = tag; 307 | const name = config.input_name || existingRelease.name || tag; 308 | // revisit: support a new body-concat-strategy input for accumulating 309 | // body parts as a release gets updated. some users will likely want this while 310 | // others won't previously this was duplicating content for most which 311 | // no one wants 312 | const workflowBody = releaseBody(config) || ''; 313 | const existingReleaseBody = existingRelease.body || ''; 314 | let body: string; 315 | if (config.input_append_body && workflowBody && existingReleaseBody) { 316 | body = existingReleaseBody + '\n' + workflowBody; 317 | } else { 318 | body = workflowBody || existingReleaseBody; 319 | } 320 | 321 | const prerelease = 322 | config.input_prerelease !== undefined ? config.input_prerelease : existingRelease.prerelease; 323 | 324 | const make_latest = config.input_make_latest; 325 | 326 | const release = await releaser.updateRelease({ 327 | owner, 328 | repo, 329 | release_id, 330 | tag_name, 331 | target_commitish, 332 | name, 333 | body, 334 | draft: existingRelease.draft, 335 | prerelease, 336 | discussion_category_name, 337 | generate_release_notes, 338 | make_latest, 339 | }); 340 | return release.data; 341 | } catch (error) { 342 | if (error.status !== 404) { 343 | console.log( 344 | `⚠️ Unexpected error fetching GitHub release for tag ${config.github_ref}: ${error}`, 345 | ); 346 | throw error; 347 | } 348 | 349 | return await createRelease( 350 | tag, 351 | config, 352 | releaser, 353 | owner, 354 | repo, 355 | discussion_category_name, 356 | generate_release_notes, 357 | maxRetries, 358 | ); 359 | } 360 | }; 361 | 362 | /** 363 | * Finalizes a release by unmarking it as "draft" (if relevant) 364 | * after all artifacts have been uploaded. 365 | * 366 | * @param config - Release configuration as specified by user 367 | * @param releaser - The GitHub API wrapper for release operations 368 | * @param release - The existing release to be finalized 369 | * @param maxRetries - The maximum number of attempts to finalize the release 370 | */ 371 | export const finalizeRelease = async ( 372 | config: Config, 373 | releaser: Releaser, 374 | release: Release, 375 | maxRetries: number = 3, 376 | ): Promise => { 377 | if (config.input_draft === true) { 378 | return release; 379 | } 380 | 381 | if (maxRetries <= 0) { 382 | console.log(`❌ Too many retries. Aborting...`); 383 | throw new Error('Too many retries.'); 384 | } 385 | 386 | const [owner, repo] = config.github_repository.split('/'); 387 | try { 388 | const { data } = await releaser.finalizeRelease({ 389 | owner, 390 | repo, 391 | release_id: release.id, 392 | }); 393 | 394 | return data; 395 | } catch { 396 | console.log(`retrying... (${maxRetries - 1} retries remaining)`); 397 | return finalizeRelease(config, releaser, release, maxRetries - 1); 398 | } 399 | }; 400 | 401 | /** 402 | * Finds a release by tag name from all a repository's releases. 403 | * 404 | * @param releaser - The GitHub API wrapper for release operations 405 | * @param owner - The owner of the repository 406 | * @param repo - The name of the repository 407 | * @param tag - The tag name to search for 408 | * @returns The release with the given tag name, or undefined if no release with that tag name is found 409 | */ 410 | export async function findTagFromReleases( 411 | releaser: Releaser, 412 | owner: string, 413 | repo: string, 414 | tag: string, 415 | ): Promise { 416 | for await (const { data: releases } of releaser.allReleases({ 417 | owner, 418 | repo, 419 | })) { 420 | const release = releases.find((release) => release.tag_name === tag); 421 | if (release) { 422 | return release; 423 | } 424 | } 425 | return undefined; 426 | } 427 | 428 | async function createRelease( 429 | tag: string, 430 | config: Config, 431 | releaser: Releaser, 432 | owner: string, 433 | repo: string, 434 | discussion_category_name: string | undefined, 435 | generate_release_notes: boolean | undefined, 436 | maxRetries: number, 437 | ) { 438 | const tag_name = tag; 439 | const name = config.input_name || tag; 440 | const body = releaseBody(config); 441 | const prerelease = config.input_prerelease; 442 | const target_commitish = config.input_target_commitish; 443 | const make_latest = config.input_make_latest; 444 | let commitMessage: string = ''; 445 | if (target_commitish) { 446 | commitMessage = ` using commit "${target_commitish}"`; 447 | } 448 | console.log(`👩‍🏭 Creating new GitHub release for tag ${tag_name}${commitMessage}...`); 449 | try { 450 | let release = await releaser.createRelease({ 451 | owner, 452 | repo, 453 | tag_name, 454 | name, 455 | body, 456 | draft: true, 457 | prerelease, 458 | target_commitish, 459 | discussion_category_name, 460 | generate_release_notes, 461 | make_latest, 462 | }); 463 | return release.data; 464 | } catch (error) { 465 | // presume a race with competing matrix runs 466 | console.log(`⚠️ GitHub release failed with status: ${error.status}`); 467 | console.log(`${JSON.stringify(error.response.data)}`); 468 | 469 | switch (error.status) { 470 | case 403: 471 | console.log( 472 | 'Skip retry — your GitHub token/PAT does not have the required permission to create a release', 473 | ); 474 | throw error; 475 | 476 | case 404: 477 | console.log('Skip retry - discussion category mismatch'); 478 | throw error; 479 | 480 | case 422: 481 | // Check if this is a race condition with "already_exists" error 482 | const errorData = error.response?.data; 483 | if (errorData?.errors?.[0]?.code === 'already_exists') { 484 | console.log( 485 | '⚠️ Release already exists (race condition detected), retrying to find and update existing release...', 486 | ); 487 | // Don't throw - allow retry to find existing release 488 | } else { 489 | console.log('Skip retry - validation failed'); 490 | throw error; 491 | } 492 | break; 493 | } 494 | 495 | console.log(`retrying... (${maxRetries - 1} retries remaining)`); 496 | return release(config, releaser, maxRetries - 1); 497 | } 498 | } 499 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 📦 :octocat: 3 |
4 |

5 | action gh-release 6 |

7 | 8 |

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

11 | 12 |
13 | 14 |
15 | 16 |
17 | 18 | 19 | 20 |
21 | 22 |
23 | 24 | - [🤸 Usage](#-usage) 25 | - [🚥 Limit releases to pushes to tags](#-limit-releases-to-pushes-to-tags) 26 | - [⬆️ Uploading release assets](#️-uploading-release-assets) 27 | - [📝 External release notes](#-external-release-notes) 28 | - [💅 Customizing](#-customizing) 29 | - [inputs](#inputs) 30 | - [outputs](#outputs) 31 | - [environment variables](#environment-variables) 32 | - [Permissions](#permissions) 33 | 34 | ## 🤸 Usage 35 | 36 | ### 🚥 Limit releases to pushes to tags 37 | 38 | Typically usage of this action involves adding a step to a build that 39 | is gated pushes to git tags. You may find `step.if` field helpful in accomplishing this 40 | as it maximizes the reuse value of your workflow for non-tag pushes. 41 | 42 | Below is a simple example of `step.if` tag gating 43 | 44 | ```yaml 45 | name: Main 46 | 47 | on: push 48 | 49 | jobs: 50 | build: 51 | runs-on: ubuntu-latest 52 | steps: 53 | - name: Checkout 54 | uses: actions/checkout@v6 55 | - name: Release 56 | uses: softprops/action-gh-release@v2 57 | if: github.ref_type == 'tag' 58 | ``` 59 | 60 | You can also use push config tag filter 61 | 62 | ```yaml 63 | name: Main 64 | 65 | on: 66 | push: 67 | tags: 68 | - "v*.*.*" 69 | 70 | jobs: 71 | build: 72 | runs-on: ubuntu-latest 73 | steps: 74 | - name: Checkout 75 | uses: actions/checkout@v6 76 | - name: Release 77 | uses: softprops/action-gh-release@v2 78 | ``` 79 | 80 | ### ⬆️ Uploading release assets 81 | 82 | You can configure a number of options for your 83 | GitHub release and all are optional. 84 | 85 | A common case for GitHub releases is to upload your binary after its been validated and packaged. 86 | Use the `with.files` input to declare a newline-delimited list of glob expressions matching the files 87 | you wish to upload to GitHub releases. If you'd like you can just list the files by name directly. 88 | If a tag already has a GitHub release, the existing release will be updated with the release assets. 89 | 90 | Below is an example of uploading a single asset named `Release.txt` 91 | 92 | ```yaml 93 | name: Main 94 | 95 | on: push 96 | 97 | jobs: 98 | build: 99 | runs-on: ubuntu-latest 100 | steps: 101 | - name: Checkout 102 | uses: actions/checkout@v6 103 | - name: Build 104 | run: echo ${{ github.sha }} > Release.txt 105 | - name: Test 106 | run: cat Release.txt 107 | - name: Release 108 | uses: softprops/action-gh-release@v2 109 | if: github.ref_type == 'tag' 110 | with: 111 | files: Release.txt 112 | ``` 113 | 114 | Below is an example of uploading more than one asset with a GitHub release 115 | 116 | ```yaml 117 | name: Main 118 | 119 | on: push 120 | 121 | jobs: 122 | build: 123 | runs-on: ubuntu-latest 124 | steps: 125 | - name: Checkout 126 | uses: actions/checkout@v6 127 | - name: Build 128 | run: echo ${{ github.sha }} > Release.txt 129 | - name: Test 130 | run: cat Release.txt 131 | - name: Release 132 | uses: softprops/action-gh-release@v2 133 | if: github.ref_type == 'tag' 134 | with: 135 | files: | 136 | Release.txt 137 | LICENSE 138 | ``` 139 | 140 | > **⚠️ Note:** Notice the `|` in the yaml syntax above ☝️. That lets you effectively declare a multi-line yaml string. You can learn more about multi-line yaml syntax [here](https://yaml-multiline.info) 141 | 142 | > **⚠️ Note for Windows:** Paths must use `/` as a separator, not `\`, as `\` is used to escape characters with special meaning in the pattern; for example, instead of specifying `D:\Foo.txt`, you must specify `D:/Foo.txt`. If you're using PowerShell, you can do this with `$Path = $Path -replace '\\','/'` 143 | 144 | ### 📝 External release notes 145 | 146 | Many systems exist that can help generate release notes for you. This action supports 147 | loading release notes from a path in your repository's build to allow for the flexibility 148 | of using any changelog generator for your releases, including a human 👩‍💻 149 | 150 | ```yaml 151 | name: Main 152 | 153 | on: push 154 | 155 | jobs: 156 | build: 157 | runs-on: ubuntu-latest 158 | steps: 159 | - name: Checkout 160 | uses: actions/checkout@v6 161 | - name: Generate Changelog 162 | run: echo "# Good things have arrived" > ${{ github.workspace }}-CHANGELOG.txt 163 | - name: Release 164 | uses: softprops/action-gh-release@v2 165 | if: github.ref_type == 'tag' 166 | with: 167 | body_path: ${{ github.workspace }}-CHANGELOG.txt 168 | repository: my_gh_org/my_gh_repo 169 | # note you'll typically need to create a personal access token 170 | # with permissions to create releases in the other repo 171 | token: ${{ secrets.CUSTOM_GITHUB_TOKEN }} 172 | ``` 173 | 174 | ### 💅 Customizing 175 | 176 | #### inputs 177 | 178 | The following are optional as `step.with` keys 179 | 180 | | Name | Type | Description | 181 | | -------------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 182 | | `body` | String | Text communicating notable changes in this release | 183 | | `body_path` | String | Path to load text communicating notable changes in this release | 184 | | `draft` | Boolean | Indicator of whether or not this release is a draft | 185 | | `prerelease` | Boolean | Indicator of whether or not is a prerelease | 186 | | `preserve_order` | Boolean | Indicator of whether order of files should be preserved when uploading assets | 187 | | `files` | String | Newline-delimited globs of paths to assets to upload for release | 188 | | `overwrite_files` | Boolean | Indicator of whether files should be overwritten when they already exist. Defaults to true | 189 | | `name` | String | Name of the release. defaults to tag name | 190 | | `tag_name` | String | Name of a tag. defaults to `github.ref_name` | 191 | | `fail_on_unmatched_files` | Boolean | Indicator of whether to fail if any of the `files` globs match nothing | 192 | | `repository` | String | Name of a target repository in `/` format. Defaults to GITHUB_REPOSITORY env variable | 193 | | `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. | 194 | | `token` | String | Secret GitHub Personal Access Token. Defaults to `${{ github.token }}` | 195 | | `discussion_category_name` | String | If specified, a discussion of the specified category is created and linked to the release. The value must be a category that already exists in the repository. For more information, see ["Managing categories for discussions in your repository."](https://docs.github.com/en/discussions/managing-discussions-for-your-community/managing-categories-for-discussions-in-your-repository) | 196 | | `generate_release_notes` | Boolean | Whether to automatically generate the name and body for this release. If name is specified, the specified name will be used; otherwise, a name will be automatically generated. If body is specified, the body will be pre-pended to the automatically generated notes. See the [GitHub docs for this feature](https://docs.github.com/en/repositories/releasing-projects-on-github/automatically-generated-release-notes) for more information | 197 | | `append_body` | Boolean | Append to existing body instead of overwriting it | 198 | | `make_latest` | String | Specifies whether this release should be set as the latest release for the repository. Drafts and prereleases cannot be set as latest. Can be `true`, `false`, or `legacy`. Uses GitHub api defaults if not provided | 199 | 200 | 💡 When providing a `body` and `body_path` at the same time, `body_path` will be 201 | attempted first, then falling back on `body` if the path can not be read from. 202 | 203 | 💡 When the release info keys (such as `name`, `body`, `draft`, `prerelease`, etc.) 204 | are not explicitly set and there is already an existing release for the tag, the 205 | release will retain its original info. 206 | 207 | #### outputs 208 | 209 | The following outputs can be accessed via `${{ steps..outputs }}` from this action 210 | 211 | | Name | Type | Description | 212 | | ------------ | ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 213 | | `url` | String | Github.com URL for the release | 214 | | `id` | String | Release ID | 215 | | `upload_url` | String | URL for uploading assets to the release | 216 | | `assets` | String | JSON array containing information about each uploaded asset, in the format given [here](https://docs.github.com/en/rest/releases/assets#get-a-release-asset) (minus the `uploader` field) | 217 | 218 | As an example, you can use `${{ fromJSON(steps..outputs.assets)[0].browser_download_url }}` to get the download URL of the first asset. 219 | 220 | #### environment variables 221 | 222 | The following `step.env` keys are allowed as a fallback but deprecated in favor of using inputs. 223 | 224 | | Name | Description | 225 | | ------------------- | ------------------------------------------------------------------------------------------ | 226 | | `GITHUB_TOKEN` | GITHUB_TOKEN as provided by `secrets` | 227 | | `GITHUB_REPOSITORY` | Name of a target repository in `/` format. defaults to the current repository | 228 | 229 | > **⚠️ Note:** This action was previously implemented as a Docker container, limiting its use to GitHub Actions Linux virtual environments only. With recent releases, we now support cross platform usage. You'll need to remove the `docker://` prefix in these versions 230 | 231 | ### Permissions 232 | 233 | This Action requires the following permissions on the GitHub integration token: 234 | 235 | ```yaml 236 | permissions: 237 | contents: write 238 | ``` 239 | 240 | When used with `discussion_category_name`, additional permission is needed: 241 | 242 | ```yaml 243 | permissions: 244 | contents: write 245 | discussions: write 246 | ``` 247 | 248 | [GitHub token permissions](https://docs.github.com/en/actions/security-guides/automatic-token-authentication#permissions-for-the-github_token) can be set for an individual job, workflow, or for Actions as a whole. 249 | 250 | Note that if you intend to run workflows on the release event (`on: { release: { types: [published] } }`), you need to use 251 | a personal access token for this action, as the [default `secrets.GITHUB_TOKEN` does not trigger another workflow](https://github.com/actions/create-release/issues/71). 252 | 253 | Doug Tangren (softprops) 2019 254 | -------------------------------------------------------------------------------- /__tests__/util.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | alignAssetName, 3 | isTag, 4 | parseConfig, 5 | parseInputFiles, 6 | paths, 7 | releaseBody, 8 | unmatchedPatterns, 9 | uploadUrl, 10 | } from '../src/util'; 11 | 12 | import { assert, describe, expect, it } from 'vitest'; 13 | 14 | describe('util', () => { 15 | describe('uploadUrl', () => { 16 | it('strips template', () => { 17 | assert.equal( 18 | uploadUrl( 19 | 'https://uploads.github.com/repos/octocat/Hello-World/releases/1/assets{?name,label}', 20 | ), 21 | 'https://uploads.github.com/repos/octocat/Hello-World/releases/1/assets', 22 | ); 23 | }); 24 | }); 25 | describe('parseInputFiles', () => { 26 | it('parses empty strings', () => { 27 | assert.deepStrictEqual(parseInputFiles(''), []); 28 | }); 29 | it('parses comma-delimited strings', () => { 30 | assert.deepStrictEqual(parseInputFiles('foo,bar'), ['foo', 'bar']); 31 | }); 32 | it('parses newline and comma-delimited (and then some)', () => { 33 | assert.deepStrictEqual(parseInputFiles('foo,bar\nbaz,boom,\n\ndoom,loom '), [ 34 | 'foo', 35 | 'bar', 36 | 'baz', 37 | 'boom', 38 | 'doom', 39 | 'loom', 40 | ]); 41 | }); 42 | it('handles globs with brace groups containing commas', () => { 43 | assert.deepStrictEqual(parseInputFiles('./**/*.{exe,deb,tar.gz}\nfoo,bar'), [ 44 | './**/*.{exe,deb,tar.gz}', 45 | 'foo', 46 | 'bar', 47 | ]); 48 | }); 49 | it('handles single-line brace pattern correctly', () => { 50 | assert.deepStrictEqual(parseInputFiles('./**/*.{exe,deb,tar.gz}'), [ 51 | './**/*.{exe,deb,tar.gz}', 52 | ]); 53 | }); 54 | }); 55 | describe('releaseBody', () => { 56 | it('uses input body', () => { 57 | assert.equal( 58 | 'foo', 59 | releaseBody({ 60 | github_ref: '', 61 | github_repository: '', 62 | github_token: '', 63 | input_body: 'foo', 64 | input_body_path: undefined, 65 | input_draft: false, 66 | input_prerelease: false, 67 | input_preserve_order: undefined, 68 | input_files: [], 69 | input_overwrite_files: undefined, 70 | input_name: undefined, 71 | input_tag_name: undefined, 72 | input_target_commitish: undefined, 73 | input_discussion_category_name: undefined, 74 | input_generate_release_notes: false, 75 | input_make_latest: undefined, 76 | }), 77 | ); 78 | }); 79 | it('uses input body path', () => { 80 | assert.equal( 81 | 'bar', 82 | releaseBody({ 83 | github_ref: '', 84 | github_repository: '', 85 | github_token: '', 86 | input_body: undefined, 87 | input_body_path: '__tests__/release.txt', 88 | input_draft: false, 89 | input_prerelease: false, 90 | input_preserve_order: undefined, 91 | input_files: [], 92 | input_overwrite_files: undefined, 93 | input_name: undefined, 94 | input_tag_name: undefined, 95 | input_target_commitish: undefined, 96 | input_discussion_category_name: undefined, 97 | input_generate_release_notes: false, 98 | input_make_latest: undefined, 99 | }), 100 | ); 101 | }); 102 | it('defaults to body path when both body and body path are provided', () => { 103 | assert.equal( 104 | 'bar', 105 | releaseBody({ 106 | github_ref: '', 107 | github_repository: '', 108 | github_token: '', 109 | input_body: 'foo', 110 | input_body_path: '__tests__/release.txt', 111 | input_draft: false, 112 | input_prerelease: false, 113 | input_preserve_order: undefined, 114 | input_files: [], 115 | input_overwrite_files: undefined, 116 | input_name: undefined, 117 | input_tag_name: undefined, 118 | input_target_commitish: undefined, 119 | input_discussion_category_name: undefined, 120 | input_generate_release_notes: false, 121 | input_make_latest: undefined, 122 | }), 123 | ); 124 | }); 125 | it('falls back to body when body_path is missing', () => { 126 | assert.equal( 127 | releaseBody({ 128 | github_ref: '', 129 | github_repository: '', 130 | github_token: '', 131 | input_body: 'fallback-body', 132 | input_body_path: '__tests__/does-not-exist.txt', 133 | input_draft: false, 134 | input_prerelease: false, 135 | input_files: [], 136 | input_overwrite_files: undefined, 137 | input_preserve_order: undefined, 138 | input_name: undefined, 139 | input_tag_name: undefined, 140 | input_target_commitish: undefined, 141 | input_discussion_category_name: undefined, 142 | input_generate_release_notes: false, 143 | input_make_latest: undefined, 144 | }), 145 | 'fallback-body', 146 | ); 147 | }); 148 | it('returns undefined when body_path is missing and body is not provided', () => { 149 | assert.equal( 150 | releaseBody({ 151 | github_ref: '', 152 | github_repository: '', 153 | github_token: '', 154 | input_body: undefined, 155 | input_body_path: '__tests__/does-not-exist.txt', 156 | input_draft: false, 157 | input_prerelease: false, 158 | input_files: [], 159 | input_overwrite_files: undefined, 160 | input_preserve_order: undefined, 161 | input_name: undefined, 162 | input_tag_name: undefined, 163 | input_target_commitish: undefined, 164 | input_discussion_category_name: undefined, 165 | input_generate_release_notes: false, 166 | input_make_latest: undefined, 167 | }), 168 | undefined, 169 | ); 170 | }); 171 | }); 172 | describe('parseConfig', () => { 173 | it('parses basic config', () => { 174 | assert.deepStrictEqual( 175 | parseConfig({ 176 | // note: inputs declared in actions.yml, even when declared not required, 177 | // are still provided by the actions runtime env as empty strings instead of 178 | // the normal absent env value one would expect. this breaks things 179 | // as an empty string !== undefined in terms of what we pass to the api 180 | // so we cover that in a test case here to ensure undefined values are actually 181 | // resolved as undefined and not empty strings 182 | INPUT_TARGET_COMMITISH: '', 183 | INPUT_DISCUSSION_CATEGORY_NAME: '', 184 | }), 185 | { 186 | github_ref: '', 187 | github_repository: '', 188 | github_token: '', 189 | input_working_directory: undefined, 190 | input_append_body: false, 191 | input_body: undefined, 192 | input_body_path: undefined, 193 | input_draft: undefined, 194 | input_prerelease: undefined, 195 | input_preserve_order: undefined, 196 | input_files: [], 197 | input_overwrite_files: undefined, 198 | input_name: undefined, 199 | input_tag_name: undefined, 200 | input_fail_on_unmatched_files: false, 201 | input_target_commitish: undefined, 202 | input_discussion_category_name: undefined, 203 | input_generate_release_notes: false, 204 | input_make_latest: undefined, 205 | }, 206 | ); 207 | }); 208 | 209 | it('parses basic config with commitish', () => { 210 | assert.deepStrictEqual( 211 | parseConfig({ 212 | INPUT_TARGET_COMMITISH: 'affa18ef97bc9db20076945705aba8c516139abd', 213 | }), 214 | { 215 | github_ref: '', 216 | github_repository: '', 217 | github_token: '', 218 | input_working_directory: undefined, 219 | input_append_body: false, 220 | input_body: undefined, 221 | input_body_path: undefined, 222 | input_draft: undefined, 223 | input_prerelease: undefined, 224 | input_files: [], 225 | input_overwrite_files: undefined, 226 | input_preserve_order: undefined, 227 | input_name: undefined, 228 | input_tag_name: undefined, 229 | input_fail_on_unmatched_files: false, 230 | input_target_commitish: 'affa18ef97bc9db20076945705aba8c516139abd', 231 | input_discussion_category_name: undefined, 232 | input_generate_release_notes: false, 233 | input_make_latest: undefined, 234 | }, 235 | ); 236 | }); 237 | it('supports discussion category names', () => { 238 | assert.deepStrictEqual( 239 | parseConfig({ 240 | INPUT_DISCUSSION_CATEGORY_NAME: 'releases', 241 | }), 242 | { 243 | github_ref: '', 244 | github_repository: '', 245 | github_token: '', 246 | input_working_directory: undefined, 247 | input_append_body: false, 248 | input_body: undefined, 249 | input_body_path: undefined, 250 | input_draft: undefined, 251 | input_prerelease: undefined, 252 | input_files: [], 253 | input_preserve_order: undefined, 254 | input_name: undefined, 255 | input_overwrite_files: undefined, 256 | input_tag_name: undefined, 257 | input_fail_on_unmatched_files: false, 258 | input_target_commitish: undefined, 259 | input_discussion_category_name: 'releases', 260 | input_generate_release_notes: false, 261 | input_make_latest: undefined, 262 | }, 263 | ); 264 | }); 265 | 266 | it('supports generating release notes', () => { 267 | assert.deepStrictEqual( 268 | parseConfig({ 269 | INPUT_GENERATE_RELEASE_NOTES: 'true', 270 | }), 271 | { 272 | github_ref: '', 273 | github_repository: '', 274 | github_token: '', 275 | input_working_directory: undefined, 276 | input_append_body: false, 277 | input_body: undefined, 278 | input_body_path: undefined, 279 | input_draft: undefined, 280 | input_prerelease: undefined, 281 | input_preserve_order: undefined, 282 | input_files: [], 283 | input_overwrite_files: undefined, 284 | input_name: undefined, 285 | input_tag_name: undefined, 286 | input_fail_on_unmatched_files: false, 287 | input_target_commitish: undefined, 288 | input_discussion_category_name: undefined, 289 | input_generate_release_notes: true, 290 | input_make_latest: undefined, 291 | }, 292 | ); 293 | }); 294 | 295 | it('prefers GITHUB_TOKEN over token input for backwards compatibility', () => { 296 | assert.deepStrictEqual( 297 | parseConfig({ 298 | INPUT_DRAFT: 'false', 299 | INPUT_PRERELEASE: 'true', 300 | INPUT_PRESERVE_ORDER: 'true', 301 | GITHUB_TOKEN: 'env-token', 302 | INPUT_TOKEN: 'input-token', 303 | }), 304 | { 305 | github_ref: '', 306 | github_repository: '', 307 | github_token: 'env-token', 308 | input_working_directory: undefined, 309 | input_append_body: false, 310 | input_body: undefined, 311 | input_body_path: undefined, 312 | input_draft: false, 313 | input_prerelease: true, 314 | input_preserve_order: true, 315 | input_files: [], 316 | input_overwrite_files: undefined, 317 | input_name: undefined, 318 | input_tag_name: undefined, 319 | input_fail_on_unmatched_files: false, 320 | input_target_commitish: undefined, 321 | input_discussion_category_name: undefined, 322 | input_generate_release_notes: false, 323 | input_make_latest: undefined, 324 | }, 325 | ); 326 | }); 327 | it('uses input token as the source of GITHUB_TOKEN by default', () => { 328 | assert.deepStrictEqual( 329 | parseConfig({ 330 | INPUT_DRAFT: 'false', 331 | INPUT_PRERELEASE: 'true', 332 | INPUT_TOKEN: 'input-token', 333 | }), 334 | { 335 | github_ref: '', 336 | github_repository: '', 337 | github_token: 'input-token', 338 | input_working_directory: undefined, 339 | input_append_body: false, 340 | input_body: undefined, 341 | input_body_path: undefined, 342 | input_draft: false, 343 | input_prerelease: true, 344 | input_preserve_order: undefined, 345 | input_files: [], 346 | input_overwrite_files: undefined, 347 | input_name: undefined, 348 | input_tag_name: undefined, 349 | input_fail_on_unmatched_files: false, 350 | input_target_commitish: undefined, 351 | input_discussion_category_name: undefined, 352 | input_generate_release_notes: false, 353 | input_make_latest: undefined, 354 | }, 355 | ); 356 | }); 357 | it('parses basic config with draft and prerelease', () => { 358 | assert.deepStrictEqual( 359 | parseConfig({ 360 | INPUT_DRAFT: 'false', 361 | INPUT_PRERELEASE: 'true', 362 | }), 363 | { 364 | github_ref: '', 365 | github_repository: '', 366 | github_token: '', 367 | input_working_directory: undefined, 368 | input_append_body: false, 369 | input_body: undefined, 370 | input_body_path: undefined, 371 | input_draft: false, 372 | input_prerelease: true, 373 | input_preserve_order: undefined, 374 | input_files: [], 375 | input_overwrite_files: undefined, 376 | input_name: undefined, 377 | input_tag_name: undefined, 378 | input_fail_on_unmatched_files: false, 379 | input_target_commitish: undefined, 380 | input_discussion_category_name: undefined, 381 | input_generate_release_notes: false, 382 | input_make_latest: undefined, 383 | }, 384 | ); 385 | }); 386 | it('parses basic config where make_latest is passed', () => { 387 | assert.deepStrictEqual( 388 | parseConfig({ 389 | INPUT_MAKE_LATEST: 'false', 390 | }), 391 | { 392 | github_ref: '', 393 | github_repository: '', 394 | github_token: '', 395 | input_working_directory: undefined, 396 | input_append_body: false, 397 | input_body: undefined, 398 | input_body_path: undefined, 399 | input_draft: undefined, 400 | input_prerelease: undefined, 401 | input_preserve_order: undefined, 402 | input_files: [], 403 | input_name: undefined, 404 | input_overwrite_files: undefined, 405 | input_tag_name: undefined, 406 | input_fail_on_unmatched_files: false, 407 | input_target_commitish: undefined, 408 | input_discussion_category_name: undefined, 409 | input_generate_release_notes: false, 410 | input_make_latest: 'false', 411 | }, 412 | ); 413 | }); 414 | it('parses basic config with append_body', () => { 415 | assert.deepStrictEqual( 416 | parseConfig({ 417 | INPUT_APPEND_BODY: 'true', 418 | }), 419 | { 420 | github_ref: '', 421 | github_repository: '', 422 | github_token: '', 423 | input_working_directory: undefined, 424 | input_append_body: true, 425 | input_body: undefined, 426 | input_body_path: undefined, 427 | input_draft: undefined, 428 | input_prerelease: undefined, 429 | input_preserve_order: undefined, 430 | input_files: [], 431 | input_overwrite_files: undefined, 432 | input_name: undefined, 433 | input_tag_name: undefined, 434 | input_fail_on_unmatched_files: false, 435 | input_target_commitish: undefined, 436 | input_discussion_category_name: undefined, 437 | input_generate_release_notes: false, 438 | input_make_latest: undefined, 439 | }, 440 | ); 441 | }); 442 | }); 443 | describe('isTag', () => { 444 | it('returns true for tags', async () => { 445 | assert.equal(isTag('refs/tags/foo'), true); 446 | }); 447 | it('returns false for other kinds of refs', async () => { 448 | assert.equal(isTag('refs/heads/master'), false); 449 | }); 450 | }); 451 | 452 | describe('paths', () => { 453 | it('resolves files given a set of paths', async () => { 454 | assert.deepStrictEqual(paths(['tests/data/**/*', 'tests/data/does/not/exist/*']), [ 455 | 'tests/data/foo/bar.txt', 456 | ]); 457 | }); 458 | 459 | it('resolves files relative to working_directory', async () => { 460 | assert.deepStrictEqual(paths(['data/**/*'], 'tests'), ['tests/data/foo/bar.txt']); 461 | }); 462 | }); 463 | 464 | describe('unmatchedPatterns', () => { 465 | it("returns the patterns that don't match any files", async () => { 466 | assert.deepStrictEqual( 467 | unmatchedPatterns(['tests/data/**/*', 'tests/data/does/not/exist/*']), 468 | ['tests/data/does/not/exist/*'], 469 | ); 470 | }); 471 | 472 | it('resolves unmatched relative to working_directory', async () => { 473 | assert.deepStrictEqual(unmatchedPatterns(['data/does/not/exist/*'], 'tests'), [ 474 | 'data/does/not/exist/*', 475 | ]); 476 | }); 477 | }); 478 | 479 | describe('replaceSpacesWithDots', () => { 480 | it('replaces all spaces with dots', () => { 481 | expect(alignAssetName('John Doe.bla')).toBe('John.Doe.bla'); 482 | }); 483 | 484 | it('handles names with multiple spaces', () => { 485 | expect(alignAssetName('John William Doe.bla')).toBe('John.William.Doe.bla'); 486 | }); 487 | 488 | it('returns the same string if there are no spaces', () => { 489 | expect(alignAssetName('JohnDoe')).toBe('JohnDoe'); 490 | }); 491 | }); 492 | }); 493 | 494 | describe('parseInputFiles edge cases', () => { 495 | it('handles multiple brace groups on same line', () => { 496 | assert.deepStrictEqual(parseInputFiles('./**/*.{exe,deb},./dist/**/*.{zip,tar.gz}'), [ 497 | './**/*.{exe,deb}', 498 | './dist/**/*.{zip,tar.gz}', 499 | ]); 500 | }); 501 | 502 | it('handles nested braces', () => { 503 | assert.deepStrictEqual(parseInputFiles('path/{a,{b,c}}/file.txt'), ['path/{a,{b,c}}/file.txt']); 504 | }); 505 | 506 | it('handles empty comma-separated values', () => { 507 | assert.deepStrictEqual(parseInputFiles('foo,,bar'), ['foo', 'bar']); 508 | }); 509 | 510 | it('handles commas with spaces around braces', () => { 511 | assert.deepStrictEqual(parseInputFiles(' ./**/*.{exe,deb} , file.txt '), [ 512 | './**/*.{exe,deb}', 513 | 'file.txt', 514 | ]); 515 | }); 516 | 517 | it('handles mixed newlines and commas with braces', () => { 518 | assert.deepStrictEqual(parseInputFiles('file1.txt\n./**/*.{exe,deb},file2.txt\nfile3.txt'), [ 519 | 'file1.txt', 520 | './**/*.{exe,deb}', 521 | 'file2.txt', 522 | 'file3.txt', 523 | ]); 524 | }); 525 | }); 526 | --------------------------------------------------------------------------------