├── .npmignore ├── bin └── github-release ├── .gitignore ├── test └── index.js ├── babel.config.js ├── .github ├── FUNDING.yml └── workflows │ └── node.js.yml ├── LICENSE ├── package.json ├── README.md └── src └── index.js /.npmignore: -------------------------------------------------------------------------------- 1 | npm-debug.log 2 | /src 3 | /test 4 | -------------------------------------------------------------------------------- /bin/github-release: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | require('../lib'); 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log 3 | /lib 4 | package-lock.json 5 | yarn.lock 6 | -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | import { test } from 'tap'; 2 | 3 | test('noop', (t) => { 4 | t.end(); 5 | }); 6 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: '@trendmicro/babel-config', 3 | presets: [ 4 | [ 5 | '@babel/preset-env', 6 | { 7 | useBuiltIns: 'entry', 8 | corejs: 3, 9 | } 10 | ], 11 | ], 12 | plugins: [ 13 | '@babel/plugin-transform-runtime', 14 | ], 15 | }; 16 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: cheton # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 13 | -------------------------------------------------------------------------------- /.github/workflows/node.js.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, cache/restore them, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Node.js CI 5 | 6 | on: 7 | push: 8 | branches: [ master ] 9 | pull_request: 10 | branches: [ master ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | strategy: 18 | matrix: 19 | node-version: [12.x, 14.x, 16.x] 20 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 21 | 22 | steps: 23 | - uses: actions/checkout@v2 24 | - name: Use Node.js ${{ matrix.node-version }} 25 | uses: actions/setup-node@v2 26 | with: 27 | node-version: ${{ matrix.node-version }} 28 | - run: yarn install 29 | - run: yarn build 30 | - run: yarn test 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017-2020 Cheton Wu 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "github-release-cli", 3 | "version": "2.1.0", 4 | "description": "A command-line tool for managing release assets on a GitHub repository", 5 | "homepage": "https://github.com/cheton/github-release-cli", 6 | "author": "Cheton Wu ", 7 | "bin": { 8 | "github-release": "./bin/github-release" 9 | }, 10 | "scripts": { 11 | "prepublish": "npm run build", 12 | "build": "babel --out-dir ./lib ./src", 13 | "test": "tap test/*.js --node-arg=--require --node-arg=@babel/register", 14 | "test:list": "npm run build && node lib/index.js -a --owner cheton --repo github-release-cli list" 15 | }, 16 | "files": [ 17 | "bin", 18 | "lib" 19 | ], 20 | "repository": { 21 | "type": "git", 22 | "url": "git@github.com:cheton/github-release-cli.git" 23 | }, 24 | "license": "MIT", 25 | "preferGlobal": true, 26 | "keywords": [ 27 | "github", 28 | "release", 29 | "cli" 30 | ], 31 | "dependencies": { 32 | "@babel/runtime": "7.x", 33 | "@octokit/rest": "18.x", 34 | "chalk": "^4.1.0", 35 | "commander": "^6.1.0", 36 | "http-link-header": "^1.0.2", 37 | "mime-types": "^2.1.27", 38 | "minimatch": "^3.0.4", 39 | "ora": "^5.1.0", 40 | "url-parse": "^1.4.7" 41 | }, 42 | "devDependencies": { 43 | "@babel/cli": "7.x", 44 | "@babel/core": "7.x", 45 | "@babel/plugin-transform-runtime": "7.x", 46 | "@babel/preset-env": "7.x", 47 | "@babel/register": "7.x", 48 | "@trendmicro/babel-config": "^1.0.2", 49 | "tap": "^14.10.8" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # github-release-cli [![build status](https://travis-ci.org/cheton/github-release-cli.svg?branch=master)](https://travis-ci.org/cheton/github-release-cli) 2 | 3 | [![NPM](https://nodei.co/npm/github-release-cli.png?downloads=true&stars=true)](https://www.npmjs.com/package/github-release-cli) 4 | 5 | A command-line tool for managing release assets on a GitHub repository. 6 | 7 | ## Installation 8 | 9 | ``` 10 | npm install -g github-release-cli 11 | ``` 12 | 13 | ## Command Line Usage 14 | 15 | Run `github-release` with `-h` or `--help` options: 16 | 17 | ``` 18 | Usage: github-release [] 19 | 20 | Options: 21 | -V, --version output the version number 22 | --baseurl API endpoint (default: "https://api.github.com") 23 | --token OAuth2 token (default: null) 24 | --owner The repository owner. (default: "") 25 | --repo The repository name. (default: "") 26 | --tag The name of the tag. 27 | --commitish Specifies the commitish value for tag. Unused if the tag already exists. 28 | --release-id The release id. 29 | --release-name The name of the release. (default: "") 30 | --body Text describing the contents of the tag. 31 | --draft [value] `true` makes the release a draft, and `false` publishes the release. 32 | --prerelease [value] `true` to identify the release as a prerelease, `false` to identify the release as a full release. 33 | -h, --help display help for command 34 | ``` 35 | 36 | ## Commands 37 | 38 | ### List 39 | 40 | ```sh 41 | github-release list 42 | --owner cheton \ 43 | --repo github-release-cli 44 | ``` 45 | 46 | ### Upload 47 | 48 | ```sh 49 | github-release upload \ 50 | --owner cheton \ 51 | --repo github-release-cli \ 52 | --tag "v0.1.0" \ 53 | --release-name "v0.1.0" \ 54 | --body "This release contains bug fixes and imporvements, including:\n..." \ 55 | archive.zip index.html app.min.css app.min.js 56 | ``` 57 | 58 | #### Specify the commitish value for tag 59 | 60 | ```sh 61 | github-release upload \ 62 | --owner cheton \ 63 | --repo github-release-cli \ 64 | --commitish 6a8e375 \ 65 | --tag "v0.1.0" \ 66 | --release-name "v0.1.0" \ 67 | --body "The commitish value for tag" 68 | ``` 69 | 70 | #### Create a prerelease 71 | 72 | ```sh 73 | github-release upload \ 74 | --owner cheton \ 75 | --repo github-release-cli \ 76 | --tag "v0.1.0" \ 77 | --release-name "v0.1.0" \ 78 | --body "This is a prerelease" \ 79 | --prerelease 80 | ``` 81 | 82 | #### Change a prerelease to a published release 83 | 84 | ```sh 85 | github-release upload \ 86 | --owner cheton \ 87 | --repo github-release-cli \ 88 | --tag "v0.1.0" \ 89 | --release-name "v0.1.0" \ 90 | --body "This is a published release" \ 91 | --prerelease=false 92 | ``` 93 | 94 | ### Delete 95 | 96 | #### Delete release assets 97 | 98 | You can use glob expressions to match files: 99 | ```sh 100 | github-release delete \ 101 | --owner cheton \ 102 | --repo github-release-cli \ 103 | --tag "v0.1.0" \ 104 | archive.zip index.html "app.*" 105 | ``` 106 | 107 | #### Delete a release by specifying the tag name 108 | 109 | ```sh 110 | github-release delete \ 111 | --owner cheton \ 112 | --repo github-release-cli \ 113 | --tag "v0.1.0" 114 | ``` 115 | 116 | #### Delete a release by specifying the release id 117 | 118 | ```sh 119 | github-release delete \ 120 | --owner cheton \ 121 | --repo github-release-cli \ 122 | --release-id 17994985 123 | ``` 124 | 125 | ## Examples 126 | 127 | https://github.com/cncjs/cncjs-pendant-tinyweb/blob/master/.travis.yml 128 | 129 | ## Secure Setup 130 | 131 | ### 1. Get an OAuth token from GitHub 132 | 133 | First you will need to get an OAuth Token from GitHub using your own username and "note": 134 | 135 | ```sh 136 | curl \ 137 | -u 'username' \ 138 | -d '{"scopes":["repo"], "note":"Publish to GitHub Releases"}' \ 139 | https://api.github.com/authorizations 140 | ``` 141 | 142 | For users with two-factor authentication enabled, you must send the user's authentication code (i.e., one-time password) in the `X-GitHub-OTP` header: 143 | 144 | ```sh 145 | curl \ 146 | -u 'username' \ 147 | -H 'X-GitHub-OTP: 000000' \ 148 | -d '{"scopes":["repo"], "note":"Publish to GitHub Releases"}' \ 149 | https://api.github.com/authorizations 150 | ``` 151 | 152 | ### 2. Storing the OAuth token in an environment variable 153 | 154 | For reducing security risks, you can store your OAuth token in an environment variable. 155 | 156 | Export the token using the one you got from above: 157 | 158 | ```sh 159 | export GITHUB_TOKEN=your_token 160 | ``` 161 | 162 | ### 3. Set up a CI build 163 | 164 | Now you're ready to upload assets to a GitHub repository from a CI server. For example: 165 | 166 | ```sh 167 | COMMIT_LOG=`git log -1 --format='%ci %H %s'` 168 | github-release upload \ 169 | --owner=cheton \ 170 | --repo=github-release-cli \ 171 | --tag="latest" \ 172 | --release-name="${TRAVIS_BRANCH}" \ 173 | --body="${COMMIT_LOG}" \ 174 | "releases/myapp-0.1.0-win-x32.exe" \ 175 | "releases/myapp-0.1.0-win-x64.exe" 176 | ``` 177 | 178 | If you're using Travis CI, you may want to encrypt environment variables: 179 | 180 | ```sh 181 | travis encrypt GITHUB_TOKEN=your_token 182 | ``` 183 | 184 | Learn how to define encrypted variables in .travis.yml:
185 | https://docs.travis-ci.com/user/environment-variables/#Defining-encrypted-variables-in-.travis.yml 186 | 187 | ## License 188 | 189 | MIT 190 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | /* eslint no-console: 0 */ 2 | /* eslint max-len: 0 */ 3 | import fs from 'fs'; 4 | import path from 'path'; 5 | import { Octokit } from '@octokit/rest'; 6 | import chalk from 'chalk'; 7 | import program from 'commander'; 8 | import * as LinkHeader from 'http-link-header'; 9 | import * as mime from 'mime-types'; 10 | import minimatch from 'minimatch'; 11 | import parse from 'url-parse'; 12 | import ora from 'ora'; 13 | import pkg from '../package.json'; 14 | 15 | program 16 | .version(pkg.version) 17 | .usage(' []') 18 | .option('--baseurl ', 'API endpoint', 'https://api.github.com') 19 | .option('--token ', 'OAuth2 token', null) 20 | .option('--owner ', 'The repository owner.', '') 21 | .option('--repo ', 'The repository name.', '') 22 | .option('--tag ', 'The name of the tag.') 23 | .option('--commitish ', 'Specifies the commitish value for tag. Unused if the tag already exists.') 24 | .option('--release-id ', 'The release id.') 25 | .option('--release-name ', 'The name of the release.', '') 26 | .option('--body ', 'Text describing the contents of the tag.') 27 | .option('--draft [value]', '`true` makes the release a draft, and `false` publishes the release.', function(val) { 28 | if (String(val).toLowerCase() === 'false') { 29 | return false; 30 | } 31 | return true; 32 | }) 33 | .option('--prerelease [value]', '`true` to identify the release as a prerelease, `false` to identify the release as a full release.', function(val) { 34 | if (String(val).toLowerCase() === 'false') { 35 | return false; 36 | } 37 | return true; 38 | }); 39 | 40 | program.parse(process.argv); 41 | 42 | const [command, ...args] = program.args; 43 | 44 | const token = (program.token || process.env.GITHUB_TOKEN); 45 | const octokit = new Octokit({ 46 | auth: token || null, 47 | baseUrl: program.baseurl, 48 | }); 49 | 50 | const getNextPage = (response) => { 51 | if (!response.headers || !response.headers.link) { 52 | return false; 53 | } 54 | 55 | const link = LinkHeader.parse(response.headers.link).rel('next'); 56 | if (!link || !link[0]) { 57 | return false; 58 | } 59 | 60 | const url = parse(link[0].uri, null, true); 61 | if (!url.query) { 62 | return false; 63 | } 64 | 65 | const nextPage = parseInt(url.query.page); 66 | return nextPage; 67 | }; 68 | 69 | const getReleaseByTag = async ({ owner, repo, tag }) => { 70 | try { 71 | const res = await octokit.repos.getReleaseByTag({ owner, repo, tag }); 72 | const release = res.data; 73 | return release; 74 | } catch (e) { 75 | // Ignore 76 | } 77 | 78 | try { 79 | let page = 1; 80 | do { 81 | const res = await octokit.repos.listReleases({ owner, repo, page }); 82 | const releases = res.data; 83 | for (const release of releases) { 84 | if (release.tag_name === tag) { 85 | return release; 86 | } 87 | } 88 | page = getNextPage(res); 89 | } while (page) 90 | } catch (err) { 91 | // Ignore 92 | } 93 | 94 | console.log('No release found.'); 95 | return null; 96 | }; 97 | 98 | const parseBody = (str) => { 99 | try { 100 | return JSON.parse(str); 101 | } catch (err) { 102 | return str; 103 | } 104 | } 105 | 106 | const fn = { 107 | 'upload': async () => { 108 | const { 109 | owner, 110 | repo, 111 | tag, 112 | commitish, 113 | releaseId, 114 | releaseName, 115 | body, 116 | draft, 117 | prerelease, 118 | } = program; 119 | const files = args; 120 | let release; 121 | 122 | try { 123 | if (tag) { 124 | console.log(`> getReleaseByTag: owner=${owner}, repo=${repo}, tag=${tag}`); 125 | release = await getReleaseByTag({ owner, repo, tag }); 126 | } else if (releaseId) { 127 | console.log(`> getRelease: owner=${owner}, repo=${repo}, release_id=${releaseId}`); 128 | const res = await octokit.repos.getRelease({ owner, repo, release_id: releaseId }); 129 | release = res.data; 130 | } 131 | } catch (err) { 132 | // Ignore 133 | } 134 | 135 | try { 136 | if (!release) { 137 | console.log(`> createRelease: tag_name=${tag}, target_commitish=${commitish || ''}, name=${releaseName || tag}, draft=${!!draft}, prerelease=${!!prerelease}`); 138 | const res = await octokit.repos.createRelease({ 139 | owner, 140 | repo, 141 | tag_name: tag, 142 | target_commitish: commitish, 143 | name: releaseName || tag, 144 | body: parseBody(body) || '', 145 | draft: !!draft, 146 | prerelease: !!prerelease, 147 | }); 148 | release = res.data; 149 | } else { 150 | console.log(`> updateRelease: release_id=${release.id}, tag_name=${tag}, name=${releaseName || tag}`); 151 | const res = await octokit.repos.updateRelease({ 152 | owner, 153 | repo, 154 | release_id: release.id, 155 | tag_name: tag, 156 | name: releaseName || tag, 157 | body: (body === undefined) ? release.body || '' : parseBody(body) || '', 158 | draft: (draft === undefined) ? !!release.draft : false, 159 | prerelease: (prerelease === undefined) ? !!release.prerelease : false, 160 | }); 161 | release = res.data; 162 | } 163 | 164 | if (files.length > 0) { 165 | console.log(`> uploadReleaseAsset: assets_url=${release.assets_url}`); 166 | for (let i = 0; i < files.length; ++i) { 167 | const file = files[i]; 168 | console.log(` #${i + 1}: name="${path.basename(file)}" filePath="${file}"`); 169 | await octokit.repos.uploadReleaseAsset({ 170 | url: release.upload_url, 171 | data: fs.createReadStream(file), 172 | headers: { 173 | 'Content-Type': mime.lookup(file) || 'application/octet-stream', 174 | 'Content-Length': fs.statSync(file).size, 175 | }, 176 | name: path.basename(file), 177 | }); 178 | } 179 | } 180 | } catch (err) { 181 | console.error(err); 182 | } 183 | }, 184 | 'delete': async () => { 185 | const { owner, repo, tag, releaseId } = program; 186 | const patterns = args; 187 | let release; 188 | 189 | try { 190 | if (tag) { 191 | console.log(`> getReleaseByTag: owner=${owner}, repo=${repo}, tag=${tag}`); 192 | release = await getReleaseByTag({ owner, repo, tag }); 193 | } else if (releaseId) { 194 | console.log(`> getRelease: owner=${owner}, repo=${repo}, release_id=${releaseId}`); 195 | const res = await octokit.repos.getRelease({ owner, repo, release_id: releaseId }); 196 | release = res.data; 197 | } 198 | 199 | if (!release) { 200 | return; 201 | } 202 | 203 | if (patterns.length === 0) { 204 | const release_id = release.id; 205 | console.log(`> deleteRelease: release_id=${release_id}`); 206 | await octokit.repos.deleteRelease({ owner, repo, release_id: release.id }); 207 | return; 208 | } 209 | } catch (err) { 210 | console.error(err); 211 | return; 212 | } 213 | 214 | try { 215 | const release_id = release.id; 216 | console.log(`> listReleaseAssets: release_id=${release_id}`); 217 | 218 | let assets = []; 219 | let page = 1; 220 | do { 221 | const res = await octokit.repos.listReleaseAssets({ owner, repo, release_id, page }); 222 | assets = assets.concat(res.data); 223 | page = getNextPage(res); 224 | } while (page) 225 | 226 | const deleteAssets = assets.filter(asset => { 227 | return patterns.some(pattern => minimatch(asset.name, pattern)); 228 | }); 229 | console.log(` assets=${assets.length}, deleteAssets=${deleteAssets.length}`); 230 | 231 | if (deleteAssets.length > 0) { 232 | console.log('> deleteReleaseAsset:'); 233 | for (let i = 0; i < deleteAssets.length; ++i) { 234 | const asset = deleteAssets[i]; 235 | console.log(` #${i + 1}:`, { 236 | id: asset.id, 237 | name: asset.name, 238 | label: asset.label, 239 | state: asset.state, 240 | size: asset.size, 241 | download_count: asset.download_count, 242 | created_at: asset.created_at, 243 | updated_at: asset.updated_at, 244 | }); 245 | await octokit.repos.deleteReleaseAsset({ owner, repo, asset_id: asset.id }); 246 | } 247 | } 248 | } catch (err) { 249 | console.error(err); 250 | } 251 | }, 252 | 'list': async () => { 253 | const { owner, repo } = program; 254 | let releases = []; 255 | 256 | const spinner = ora({ 257 | spinner: 'point', 258 | text: 'Fetching...', 259 | }).start(); 260 | 261 | try { 262 | let page = 1; 263 | do { 264 | const res = await octokit.repos.listReleases({ owner, repo, page }); 265 | releases = releases.concat(res.data); 266 | page = getNextPage(res); 267 | } while (page) 268 | } catch (err) { 269 | console.error(err); 270 | } 271 | 272 | spinner.stop(); 273 | 274 | for (const release of releases) { 275 | let prefix = 'RELEASE'; 276 | if (release.draft) { 277 | prefix = 'DRAFT'; 278 | } 279 | if (release.prerelease) { 280 | prefix = 'PRERELEASE'; 281 | } 282 | console.log(`${chalk.blue('●')} [${prefix}] id=${chalk.cyan(release.id)}, tag_name=${chalk.yellow(JSON.stringify(release.tag_name))}, name=${chalk.yellow(JSON.stringify(release.name))}, created_at=${chalk.yellow(release.created_at)}, published_at=${chalk.yellow(release.published_at)}`); 283 | } 284 | }, 285 | }[command]; 286 | 287 | async function main() { 288 | try { 289 | typeof fn === 'function' && await fn(); 290 | } catch (err) { 291 | // message has token in the response 292 | const message = err.message.replace(/https?:[^\s]*/g, (match) => match.replace(/\?.*/, '')); 293 | console.log(message); 294 | process.exit(1); 295 | } 296 | } 297 | 298 | main() 299 | --------------------------------------------------------------------------------