├── .github └── workflows │ ├── release.yml │ └── test.yml ├── .gitignore ├── LICENSE ├── README.md ├── bin └── semantic-release.js ├── package-lock.json ├── package.json ├── src ├── index.js └── lib │ ├── ci.js │ ├── circle.js │ ├── github-actions.js │ ├── github.js │ ├── log.js │ ├── npm.js │ ├── repository.js │ └── travis.js └── test └── cli.test.js /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | "on": 3 | push: 4 | branches: 5 | - master 6 | - next 7 | - beta 8 | - "*.x" 9 | jobs: 10 | release: 11 | name: release 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v3 15 | - uses: actions/setup-node@v3 16 | with: 17 | node-version: lts/* 18 | - run: npx semantic-release 19 | env: 20 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 21 | NPM_TOKEN: ${{ secrets.SEMANTIC_RELEASE_BOT_NPM_TOKEN }} 22 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | "on": 3 | push: 4 | branches: 5 | - master 6 | pull_request: 7 | types: 8 | - opened 9 | - synchronize 10 | jobs: 11 | test_matrix: 12 | strategy: 13 | matrix: 14 | node-version: 15 | - 10 16 | - 12 17 | - 14 18 | os: 19 | - ubuntu-latest 20 | - macos-latest 21 | runs-on: ${{ matrix.os }} 22 | steps: 23 | - uses: actions/checkout@v3 24 | - name: Use Node.js ${{ matrix.node-version }} 25 | uses: actions/setup-node@v3 26 | with: 27 | node-version: ${{ matrix.node-version }} 28 | - run: npm ci 29 | - run: npm run test:ci 30 | test: 31 | runs-on: ubuntu-latest 32 | needs: test_matrix 33 | steps: 34 | - uses: actions/checkout@v2 35 | - uses: actions/setup-node@v2 36 | with: 37 | node-version: 16 38 | - uses: bahmutov/npm-install@v1 39 | - run: npm run lint 40 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.gitignore.io/api/macos,windows,linux,node 3 | 4 | ### Linux ### 5 | *~ 6 | 7 | # temporary files which can be created if a process still has a handle open of a deleted file 8 | .fuse_hidden* 9 | 10 | # KDE directory preferences 11 | .directory 12 | 13 | # Linux trash folder which might appear on any partition or disk 14 | .Trash-* 15 | 16 | # .nfs files are created when an open file is removed but is still being accessed 17 | .nfs* 18 | 19 | ### macOS ### 20 | *.DS_Store 21 | .AppleDouble 22 | .LSOverride 23 | 24 | # Icon must end with two \r 25 | Icon 26 | 27 | # Thumbnails 28 | ._* 29 | 30 | # Files that might appear in the root of a volume 31 | .DocumentRevisions-V100 32 | .fseventsd 33 | .Spotlight-V100 34 | .TemporaryItems 35 | .Trashes 36 | .VolumeIcon.icns 37 | .com.apple.timemachine.donotpresent 38 | 39 | # Directories potentially created on remote AFP share 40 | .AppleDB 41 | .AppleDesktop 42 | Network Trash Folder 43 | Temporary Items 44 | .apdisk 45 | 46 | ### Node ### 47 | # Logs 48 | logs 49 | *.log 50 | npm-debug.log* 51 | yarn-debug.log* 52 | yarn-error.log* 53 | 54 | # Runtime data 55 | pids 56 | *.pid 57 | *.seed 58 | *.pid.lock 59 | 60 | # Directory for instrumented libs generated by jscoverage/JSCover 61 | lib-cov 62 | 63 | # Coverage directory used by tools like istanbul 64 | coverage 65 | 66 | # nyc test coverage 67 | .nyc_output 68 | 69 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 70 | .grunt 71 | 72 | # Bower dependency directory (https://bower.io/) 73 | bower_components 74 | 75 | # node-waf configuration 76 | .lock-wscript 77 | 78 | # Compiled binary addons (http://nodejs.org/api/addons.html) 79 | build/Release 80 | 81 | # Dependency directories 82 | node_modules/ 83 | jspm_packages/ 84 | 85 | # Typescript v1 declaration files 86 | typings/ 87 | 88 | # Optional npm cache directory 89 | .npm 90 | 91 | # Optional eslint cache 92 | .eslintcache 93 | 94 | # Optional REPL history 95 | .node_repl_history 96 | 97 | # Output of 'npm pack' 98 | *.tgz 99 | 100 | # Yarn Integrity file 101 | .yarn-integrity 102 | 103 | # dotenv environment variables file 104 | .env 105 | 106 | 107 | ### Windows ### 108 | # Windows thumbnail cache files 109 | Thumbs.db 110 | ehthumbs.db 111 | ehthumbs_vista.db 112 | 113 | # Folder config file 114 | Desktop.ini 115 | 116 | # Recycle Bin used on file shares 117 | $RECYCLE.BIN/ 118 | 119 | # Windows Installer files 120 | *.cab 121 | *.msi 122 | *.msm 123 | *.msp 124 | 125 | # Windows shortcuts 126 | *.lnk 127 | 128 | # End of https://www.gitignore.io/api/macos,windows,linux,node 129 | 130 | yarn.lock -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Contributors 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # semantic-release-cli 2 | 3 | [![Build Status](https://github.com/semantic-release/cli/workflows/Test/badge.svg)](https://github.com/semantic-release/cli/actions?query=workflow%3ATest+) 4 | 5 | ## Install 6 | 7 | ```bash 8 | npm install -g semantic-release-cli 9 | 10 | cd your-module 11 | semantic-release-cli setup 12 | ``` 13 | 14 | ![dialogue](https://cloud.githubusercontent.com/assets/908178/8766357/f3eadaca-2e34-11e5-8ebb-d40b9ae613d7.png) 15 | 16 | ## Options 17 | 18 | Usage: 19 | semantic-release-cli setup [options] 20 | 21 | Options: 22 | -h --help Show this screen. 23 | -v --version Show version. 24 | --[no-]keychain Use keychain to get passwords [default: true]. 25 | --ask-for-passwords Ask for the passwords even if passwords are stored [default: false]. 26 | --tag= npm tag to install [default: 'latest']. 27 | --gh-token= GitHub auth token 28 | --npm-token= npm auth token 29 | --circle-token= CircleCI auth token 30 | --npm-username= npm username 31 | 32 | Aliases: 33 | init setup 34 | 35 | ## What it Does 36 | __semantic-release-cli performs the following steps:__ 37 | 38 | 1. Asks for the information it needs. You will need to provide it with: 39 | * Whether your GitHub repository is public or private 40 | * Which npm registry you want to use (Default: https://registry.npmjs.org/) 41 | * Your npm username (unless passwords were previously saved to keychain) 42 | * Your npm email 43 | * Your npm password 44 | * Which continuous integration system you want to use. (Options: Travis CI / Pro / Enterprise / CircleCI, or Other) 45 | * [Travis only] Whether you want to test a single node.js version (e.g. - 8) or multiple node.js versions (e.g. - 4, 6, 8, etc.) 46 | 1. npm Add User 47 | * Runs `npm adduser` with the npm information provided to generate a `.npmrc` 48 | * Parses the npm token from the `.npmrc` for future use 49 | 1. Uses user supplied GitHub Personal Access Token (with the following permissions: `repo`, `read:org`, `repo:status`, `repo_deployment`, `user:email`, `write:repo_hook`) 50 | * Sets GitHub Personal Access Token in user choosen CI/CD environment variable 51 | 1. Update your `package.json` 52 | * Set `version` field to `0.0.0-development` (`semantic-release` will set the version for you automatically) 53 | * Add a `semantic-release` script: `"semantic-release": "semantic-release"` 54 | * Add `semantic-release` as a `devDependency` 55 | * Add or overwrite the [`repository` field](https://docs.npmjs.com/files/package.json#repository) 56 | 57 | ## Travis CI 58 | 59 | ### Versions 4.1.0+ 60 | `semantic-release-cli` does not perform any additional Travis-specific steps, but the cli output will provide a [link](https://github.com/semantic-release/semantic-release/blob/master/docs/03-recipes/travis.md) for assistance integrating Travis and `semantic-release-cli`. 61 | 62 | ### Prior to version 4.1.0 63 | `semantic-release-cli` performs the following additional steps: 64 | 1. Overwrite your `.travis.yml` file 65 | * `after_success`: `npm install -g travis-deploy-once` and `travis-deploy-once "npm run semantic-release"`: run `semantic-release` exactly once after all builds pass 66 | * Set other sane defaults: `cache: directories: ~/.npm`, `notifications: email: false` 67 | 1. Login to Travis CI to configure the package. This step requires your module to define a valid, case-sensitive 68 | [`repository` field](https://docs.npmjs.com/files/package.json#repository). 69 | * Enable builds of your repo 70 | * Add `GH_TOKEN` and `NPM_TOKEN` environment variables in the settings 71 | 72 | ## CircleCI 73 | 74 | For CircleCI, `semantic-release-cli` performs the following additional steps: 75 | 1. Create minimal `config.yml` file (if CircleCI was selected) 76 | ```yml 77 | version: 2 78 | jobs: 79 | build: 80 | docker: 81 | - image: 'circleci/node:latest' 82 | steps: 83 | - checkout 84 | - run: 85 | name: install 86 | command: npm install 87 | - run: 88 | name: release 89 | command: npm run semantic-release || true 90 | ``` 91 | 2. Login to CircleCI to configure the package 92 | * Enable builds of your repo 93 | * Add `GH_TOKEN` and `NPM_TOKEN` environment variables in the settings 94 | 95 | 96 | ## Github Actions 97 | 98 | For Github Actions, `semantic-release-cli` performs the following additional step: 99 | * Login to Github to configure the package 100 | * Add `NPM_TOKEN` environment variables as a secret in the settings 101 | 102 | For now you will have to manually modify your existing workflow to add a release step. Here is an example of a small complete workflow `.github/workflows/workflow.yml`: 103 | ```yaml 104 | name: CI 105 | on: push 106 | jobs: 107 | test: 108 | runs-on: ubuntu-16.04 109 | steps: 110 | - uses: actions/checkout@v2 111 | - uses: actions/setup-node@v1 112 | with: 113 | node-version: '12' 114 | - run: npm ci 115 | - run: npm test 116 | - name: Release 117 | env: 118 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 119 | run: npx semantic-release 120 | ``` 121 | 122 | ## Other CI Servers 123 | 124 | By default, `semantic-release-cli` supports the popular Travis CI and CircleCI servers. If you select `Other` as your server during configuration, `semantic-release-cli` will print out the environment variables you need to set on your CI server. You will be responsible for adding these environment variables as well as configuring your CI server to run `npm run semantic-release` after all the builds pass. 125 | 126 | Note that your CI server will also need to set the environment variable `CI=true` so that `semantic-release` will not perform a dry run. (Most CI services do this by default.) See the `semantic-release` documentation for more details. 127 | 128 | ## Setting defaults 129 | 130 | This package reads your npm username from your global `.npmrc`. In order to autosuggest a username in the future, make sure to set your username there: `npm config set username `. 131 | 132 | ## Contribute 133 | 134 | Please contribute! We welcome issues and pull requests. 135 | 136 | When committing, please conform to [the semantic-release commit standards](https://github.com/semantic-release/semantic-release#default-commit-message-format). 137 | 138 | ## License 139 | 140 | MIT License 141 | 2015 © Christoph Witzko and [contributors](https://github.com/semantic-release/cli/graphs/contributors) 142 | 143 | ![https://twitter.com/trodrigues/status/509301317467373571](https://cloud.githubusercontent.com/assets/908178/6091690/cc86f58c-aeb8-11e4-94cb-15f15f486cde.png) 144 | -------------------------------------------------------------------------------- /bin/semantic-release.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | require('../src')().catch(() => { 4 | process.exitCode = 1; 5 | }); 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "semantic-release-cli", 3 | "description": "setup automated semver compliant package publishing", 4 | "version": "0.0.0-development", 5 | "author": "Christoph Witzko (http://christophwitzko.com)", 6 | "bin": { 7 | "semantic-release-cli": "./bin/semantic-release.js" 8 | }, 9 | "dependencies": { 10 | "base32": "0.0.6", 11 | "clipboardy": "^2.0.0", 12 | "git-config-path": "^2.0.0", 13 | "github-url-from-git": "^1.4.0", 14 | "ini": "^1.3.4", 15 | "inquirer": "^7.0.0", 16 | "js-yaml": "^3.3.1", 17 | "lodash": "^4.16.4", 18 | "nopt": "^4.0.0", 19 | "npm": "^6.0.0", 20 | "npm-profile": "^4.0.1", 21 | "npmlog": "^4.0.0", 22 | "p-retry": "^4.0.0", 23 | "parse-git-config": "^3.0.0", 24 | "parse-github-repo-url": "^1.0.0", 25 | "pify": "^4.0.0", 26 | "request": "^2.85.0", 27 | "request-debug": "^0.2.0", 28 | "request-promise": "^4.1.1", 29 | "travis-ci": "^2.1.1", 30 | "tweetsodium": "^0.0.5", 31 | "update-notifier": "^3.0.0", 32 | "user-home": "^2.0.0", 33 | "validator": "^13.7.0" 34 | }, 35 | "devDependencies": { 36 | "ava": "^2.0.0", 37 | "nyc": "^14.0.0", 38 | "rimraf": "^3.0.0", 39 | "semantic-release": "^19.0.3", 40 | "xo": "^0.29.0" 41 | }, 42 | "engines": { 43 | "node": ">=7.6", 44 | "npm": ">=3" 45 | }, 46 | "files": [ 47 | "bin", 48 | "src" 49 | ], 50 | "keywords": [ 51 | "automation", 52 | "changelog", 53 | "publish", 54 | "release", 55 | "semver", 56 | "version" 57 | ], 58 | "license": "MIT", 59 | "main": "src/index.js", 60 | "nyc": { 61 | "include": [ 62 | "src/**/*.js" 63 | ], 64 | "reporter": [ 65 | "json", 66 | "text", 67 | "html" 68 | ], 69 | "all": true 70 | }, 71 | "preferGlobal": true, 72 | "prettier": { 73 | "printWidth": 120, 74 | "trailingComma": "es5" 75 | }, 76 | "repository": "https://github.com/semantic-release/cli", 77 | "scripts": { 78 | "codecov": "codecov -f coverage/coverage-final.json", 79 | "lint": "xo", 80 | "pretest": "npm run lint", 81 | "semantic-release": "semantic-release", 82 | "test": "nyc ava -v", 83 | "test:ci": "nyc ava -v", 84 | "travis-deploy-once": "travis-deploy-once" 85 | }, 86 | "xo": { 87 | "prettier": true, 88 | "space": true 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | /* eslint require-atomic-updates: off */ 2 | 3 | const {readFileSync, writeFileSync} = require('fs'); 4 | const _ = require('lodash'); 5 | const pify = require('pify'); 6 | const nopt = require('nopt'); 7 | const npm = require('npm'); 8 | const request = require('request-promise').defaults({json: true}); 9 | const ownPkg = require('../package.json'); 10 | const getLog = require('./lib/log'); 11 | 12 | const pkg = JSON.parse(readFileSync('./package.json')); 13 | if (pkg.name === undefined) throw new Error(`"name" key is missing from your package.json`); 14 | 15 | require('update-notifier')({ 16 | pkg: _.defaults(ownPkg, {version: '0.0.0'}), 17 | }).notify(); 18 | 19 | const knownOptions = { 20 | tag: String, 21 | version: Boolean, 22 | help: Boolean, 23 | keychain: Boolean, 24 | 'ask-for-passwords': Boolean, 25 | 'gh-token': String, 26 | 'npm-token': String, 27 | 'circle-token': String, 28 | 'npm-username': String, 29 | }; 30 | 31 | const shortHands = { 32 | v: ['--version'], 33 | h: ['--help'], 34 | }; 35 | 36 | module.exports = async function (argv) { 37 | const info = { 38 | options: _.defaults(nopt(knownOptions, shortHands, argv, 2), { 39 | keychain: true, 40 | tag: 'latest', 41 | }), 42 | }; 43 | 44 | if (info.options.version) { 45 | console.log(ownPkg.version || 'development'); 46 | return; 47 | } 48 | 49 | if ((info.options.argv.remain[0] !== 'setup' && info.options.argv.remain[0] !== 'init') || info.options.help) { 50 | console.log(` 51 | semantic-release-cli (v${ownPkg.version}) 52 | 53 | Usage: 54 | semantic-release-cli setup [--tag=] 55 | 56 | Options: 57 | -h --help Show this screen. 58 | -v --version Show version. 59 | --[no-]keychain Use keychain to get passwords [default: true]. 60 | --ask-for-passwords Ask for the passwords even if passwords are stored [default: false]. 61 | --tag= npm tag to install [default: ’latest’]. 62 | --gh-token= GitHub auth token 63 | --npm-token= npm auth token 64 | --circle-token= CircleCI auth token 65 | --npm-username= npm username 66 | 67 | Aliases: 68 | init setup`); 69 | return; 70 | } 71 | 72 | let config; 73 | try { 74 | ({config} = await pify(npm.load)({progress: false})); 75 | } catch (error) { 76 | console.log('Failed to load npm config.', error); 77 | process.exitCode = 1; 78 | return; 79 | } 80 | 81 | info.loglevel = config.get('loglevel') || 'warn'; 82 | const log = getLog(info.loglevel); 83 | info.log = log; 84 | 85 | try { 86 | await require('./lib/repository')(pkg, info); 87 | await require('./lib/npm')(pkg, info); 88 | await require('./lib/github')(info); 89 | await require('./lib/ci')(pkg, info); 90 | } catch (error) { 91 | log.error(error); 92 | process.exitCode = 1; 93 | } 94 | 95 | pkg.version = '0.0.0-development'; 96 | 97 | pkg.scripts = pkg.scripts || {}; 98 | pkg.scripts['semantic-release'] = 'semantic-release'; 99 | 100 | pkg.repository = pkg.repository || {type: 'git', url: info.giturl}; 101 | 102 | if (info.ghrepo.private && !pkg.publishConfig) { 103 | pkg.publishConfig = {access: 'restricted'}; 104 | } 105 | 106 | try { 107 | const {'dist-tags': distTags} = await request('https://registry.npmjs.org/semantic-release'); 108 | pkg.devDependencies = pkg.devDependencies || {}; 109 | pkg.devDependencies['semantic-release'] = `^${distTags[info.options.tag]}`; 110 | } catch (error) { 111 | log.error('Could not get latest `semantic-release` version.', error); 112 | } 113 | 114 | log.verbose('Writing `package.json`.'); 115 | writeFileSync('package.json', `${JSON.stringify(pkg, null, 2)}\n`); 116 | log.info('Done.'); 117 | }; 118 | -------------------------------------------------------------------------------- /src/lib/ci.js: -------------------------------------------------------------------------------- 1 | /* eslint require-atomic-updates: off */ 2 | 3 | const _ = require('lodash'); 4 | const inquirer = require('inquirer'); 5 | const validator = require('validator'); 6 | const travis = require('./travis'); 7 | const circle = require('./circle'); 8 | const githubActions = require('./github-actions'); 9 | 10 | const cis = { 11 | 'Travis CI': travis.bind(null, 'https://api.travis-ci.org'), 12 | 'Travis CI Pro': travis.bind(null, 'https://api.travis-ci.com'), 13 | 'Travis CI Enterprise': travis, 14 | 'Circle CI': circle, 15 | 'Github Actions': githubActions, 16 | 'Other (prints tokens)': (pkg, info) => { 17 | const message = ` 18 | ${_.repeat('-', 46)} 19 | GH_TOKEN=${info.github.token} 20 | NPM_TOKEN=${info.npm.token} 21 | ${_.repeat('-', 46)} 22 | `; 23 | console.log(message); 24 | }, 25 | }; 26 | 27 | module.exports = async function (pkg, info) { 28 | const choices = _.keys(cis); 29 | 30 | const answers = await inquirer.prompt([ 31 | { 32 | type: 'list', 33 | name: 'ci', 34 | message: 'What CI are you using?', 35 | choices, 36 | default: info.ghrepo && info.ghrepo.private ? 1 : 0, 37 | }, 38 | { 39 | type: 'input', 40 | name: 'endpoint', 41 | message: 'What is your Travis CI enterprise url?', 42 | validate: _.bind(validator.isURL, null, _, {protocols: ['http', 'https'], require_protocol: true}), // eslint-disable-line camelcase 43 | when: (answers) => answers.ci === choices[2], 44 | }, 45 | ]); 46 | 47 | info.ci = answers.ci; 48 | 49 | await Reflect.apply(cis[answers.ci], null, _.compact([answers.endpoint, pkg, info])); 50 | }; 51 | -------------------------------------------------------------------------------- /src/lib/circle.js: -------------------------------------------------------------------------------- 1 | /* eslint require-atomic-updates: off */ 2 | 3 | const fs = require('fs'); 4 | const _ = require('lodash'); 5 | 6 | const clipboard = require('clipboardy'); 7 | const inquirer = require('inquirer'); 8 | const request = require('request-promise'); 9 | const yaml = require('js-yaml'); 10 | 11 | const circleConfig = { 12 | version: 2, 13 | jobs: { 14 | build: { 15 | docker: [ 16 | { 17 | image: 'circleci/node:latest', 18 | }, 19 | ], 20 | steps: [ 21 | 'checkout', 22 | { 23 | run: { 24 | name: 'install', 25 | command: 'npm install', 26 | }, 27 | }, 28 | { 29 | run: { 30 | name: 'release', 31 | command: 'npm run semantic-release || true', 32 | }, 33 | }, 34 | ], 35 | }, 36 | }, 37 | }; 38 | 39 | async function getUserInput(info) { 40 | const result = await inquirer.prompt([ 41 | { 42 | type: 'input', 43 | name: 'token', 44 | message: 'What is your CircleCI API token?', 45 | validate: (input) => (input.length === 40 ? true : 'Invalid token length'), 46 | default: async () => { 47 | const clipboardValue = await clipboard.read(); 48 | return clipboardValue.length === 40 ? clipboardValue : null; 49 | }, 50 | when: () => !_.has(info.options, 'circle-token'), 51 | }, 52 | { 53 | type: 'confirm', 54 | name: 'createConfigFile', 55 | message: 'Do you want a `config.yml` file with semantic-release setup?', 56 | default: true, 57 | }, 58 | { 59 | // Add step to existing config.yml later 60 | type: 'confirm', 61 | name: 'overwrite', 62 | default: false, 63 | message: 'Do you want to overwrite the existing `config.yml`?', 64 | when: (answers) => answers.createConfigFile && fs.existsSync('./.circleci/config.yml'), 65 | }, 66 | ]); 67 | 68 | if (_.has(info.options, 'circle-token')) { 69 | result.token = info.options['circle-token']; 70 | } 71 | 72 | return result; 73 | } 74 | 75 | async function setupCircleProject(info) { 76 | const defaultRequest = request.defaults({ 77 | headers: { 78 | 'content-type': 'application/json', 79 | accept: 'application/json', 80 | }, 81 | qs: { 82 | 'circle-token': info.circle.token, 83 | }, 84 | baseUrl: `https://circleci.com/api/v1.1/project/github/${info.ghrepo.slug[0]}/${info.ghrepo.slug[1]}/`, 85 | json: true, 86 | simple: true, 87 | }); 88 | 89 | await followProject(info, defaultRequest); 90 | await addEnvironmentVariable(info, defaultRequest, {name: 'GH_TOKEN', value: info.github.token}); 91 | 92 | if (info.npm.authmethod === 'token') { 93 | await addEnvironmentVariable(info, defaultRequest, {name: 'NPM_TOKEN', value: info.npm.token}); 94 | } else { 95 | await addEnvironmentVariable(info, defaultRequest, {name: 'NPM_USERNAME', value: info.npm.username}); 96 | await addEnvironmentVariable(info, defaultRequest, {name: 'NPM_PASSWORD', value: info.npm.password}); 97 | await addEnvironmentVariable(info, defaultRequest, {name: 'NPM_EMAIL', value: info.npm.email}); 98 | } 99 | } 100 | 101 | async function followProject(info, defaultRequest) { 102 | info.log.verbose(`Following repo ${info.ghrepo.slug[0]}/${info.ghrepo.slug[1]} on CircleCI…`); 103 | const uri = `/follow`; 104 | await defaultRequest 105 | .post(uri) 106 | .then(() => { 107 | info.log.info(`Succesfully followed repo ${info.ghrepo.slug[0]}/${info.ghrepo.slug[1]} on CircleCI.`); 108 | }) 109 | .catch(() => { 110 | info.log.error('Error following repo on CircleCI!'); 111 | process.exit(1); // eslint-disable-line unicorn/no-process-exit 112 | }); 113 | } 114 | 115 | async function addEnvironmentVariable(info, defaultRequest, body) { 116 | info.log.verbose(`Adding environment variable ${body.name} to CircleCI project…`); 117 | const uri = `/envvar`; 118 | await defaultRequest 119 | .post(uri, {body}) 120 | .then(() => { 121 | info.log.info(`Successfully added environment variable ${body.name} to CircleCI project.`); 122 | }) 123 | .catch(() => { 124 | info.log.error('Error setting environment variables on CircleCI!'); 125 | process.exit(1); // eslint-disable-line unicorn/no-process-exit 126 | }); 127 | } 128 | 129 | function setupRequestLogging(info) { 130 | require('request-debug')(request, (type, data, r) => { 131 | switch (type) { 132 | case 'request': 133 | info.log.http('request', data.method, data.uri.replace(/(.*?=.{4}).*/g, '$1xxxx')); 134 | break; 135 | case 'response': 136 | info.log.http(data.statusCode, r.uri.href.replace(/(.*?=.{4}).*/g, '$1xxxx')); 137 | info.log.verbose('response', r.response.body); 138 | break; 139 | default: 140 | break; 141 | } 142 | }); 143 | } 144 | 145 | function createConfigFile(info) { 146 | if (!info.circle.createConfigFile || (!info.circle.overwrite && fs.existsSync('./.circleci/config.yml'))) { 147 | info.log.verbose('Config file creation skipped.'); 148 | return; 149 | } 150 | 151 | if (!fs.existsSync('./.circleci/')) { 152 | info.log.verbose('Creating folder `./.circleci/`…'); 153 | fs.mkdirSync('./.circleci'); 154 | } 155 | 156 | const yml = yaml.safeDump(circleConfig); 157 | info.log.verbose('Writing `./.circleci/config.yml`…'); 158 | fs.writeFileSync('./.circleci/config.yml', yml); 159 | info.log.info('Successfully written `./.circleci/config.yml`.'); 160 | } 161 | 162 | function stopRequestLogging() { 163 | request.stopDebugging(); 164 | } 165 | 166 | module.exports = async function (pkg, info) { 167 | info.circle = await getUserInput(info); 168 | setupRequestLogging(info); 169 | await setupCircleProject(info); 170 | stopRequestLogging(); 171 | await createConfigFile(info); 172 | }; 173 | -------------------------------------------------------------------------------- /src/lib/github-actions.js: -------------------------------------------------------------------------------- 1 | /* eslint require-atomic-updates: off */ 2 | 3 | const inquirer = require('inquirer'); 4 | const request = require('request-promise').defaults({resolveWithFullResponse: true}); 5 | const validator = require('validator'); 6 | const log = require('npmlog'); 7 | const sodium = require('tweetsodium'); 8 | 9 | async function ask2FA() { 10 | return ( 11 | await inquirer.prompt([ 12 | { 13 | type: 'input', 14 | name: 'code', 15 | message: 'What is your GitHub two-factor authentication code?', 16 | validate: validator.isNumeric, 17 | }, 18 | ]) 19 | ).code; 20 | } 21 | 22 | function createEncryptedSecret(value, key) { 23 | const messageBytes = Buffer.from(value); 24 | const keyBytes = Buffer.from(key, 'base64'); 25 | 26 | const encryptedBytes = sodium.seal(messageBytes, keyBytes); 27 | 28 | return Buffer.from(encryptedBytes).toString('base64'); 29 | } 30 | 31 | async function createSecret(info) { 32 | const owner = info.ghrepo.slug[0]; 33 | const repo = info.ghrepo.slug[1]; 34 | try { 35 | const response = await request({ 36 | method: 'GET', 37 | url: `${info.github.endpoint}/repos/${owner}/${repo}/actions/secrets/public-key`, 38 | auth: { 39 | bearer: info.github.token, 40 | }, 41 | headers: {'User-Agent': 'semantic-release', 'X-GitHub-OTP': info.github.code}, 42 | }); 43 | if (response.statusCode === 200) { 44 | const {key, key_id: keyId} = JSON.parse(response.body); 45 | 46 | const encryptedValue = createEncryptedSecret(info.npm.token, key); 47 | 48 | const responsePut = await request({ 49 | method: 'PUT', 50 | url: `${info.github.endpoint}/repos/${owner}/${repo}/actions/secrets/NPM_TOKEN`, 51 | auth: { 52 | bearer: info.github.token, 53 | }, 54 | headers: {'User-Agent': 'semantic-release', 'X-GitHub-OTP': info.github.code}, 55 | json: true, 56 | body: { 57 | encrypted_value: encryptedValue, // eslint-disable-line camelcase 58 | key_id: keyId, // eslint-disable-line camelcase 59 | }, 60 | followAllRedirects: true, 61 | }); 62 | 63 | if (responsePut.statusCode !== 201 && responsePut.statusCode !== 204) { 64 | throw new Error( 65 | `Can’t add the NPM_TOKEN secret to Github Actions. Please add it manually: NPM_TOKEN=${info.npm.token}` 66 | ); 67 | } 68 | } 69 | } catch (error) { 70 | if (error.statusCode === 401 && error.response.headers['x-github-otp']) { 71 | const [, type] = error.response.headers['x-github-otp'].split('; '); 72 | 73 | if (info.github.retry) log.warn('Invalid two-factor authentication code.'); 74 | else log.info(`Two-factor authentication code needed via ${type}.`); 75 | 76 | const code = await ask2FA(); 77 | info.github.code = code; 78 | info.github.retry = true; 79 | return createSecret(info); 80 | } 81 | 82 | throw error; 83 | } 84 | } 85 | 86 | module.exports = async function (pkg, info) { 87 | await createSecret(info); 88 | 89 | log.info('Successfully created GitHub Actions NPM_TOKEN secret.'); 90 | }; 91 | -------------------------------------------------------------------------------- /src/lib/github.js: -------------------------------------------------------------------------------- 1 | /* eslint require-atomic-updates: off */ 2 | 3 | const clipboard = require('clipboardy'); 4 | const _ = require('lodash'); 5 | const inquirer = require('inquirer'); 6 | const log = require('npmlog'); 7 | 8 | module.exports = async function (info) { 9 | if (_.has(info.options, 'gh-token')) { 10 | info.github = { 11 | endpoint: info.ghepurl || 'https://api.github.com', 12 | token: info.options['gh-token'], 13 | }; 14 | log.info('Using GitHub token from command line argument.'); 15 | return; 16 | } 17 | 18 | const answers = await inquirer.prompt([ 19 | { 20 | type: 'input', 21 | name: 'token', 22 | message: 23 | 'Provide a GitHub Personal Access Token (create a token at https://github.com/settings/tokens/new?scopes=repo)', 24 | default: async () => { 25 | const clipboardValue = await clipboard.read(); 26 | return clipboardValue.length === 40 ? clipboardValue : null; 27 | }, 28 | validate: (input) => (input.length === 40 ? true : 'Invalid token length'), 29 | }, 30 | ]); 31 | 32 | info.github = answers; 33 | const {token} = info.github; 34 | 35 | if (!token) throw new Error('User could not supply GitHub Personal Access Token.'); 36 | 37 | info.github.token = token; 38 | info.github.endpoint = 'https://api.github.com'; 39 | log.info('Successfully created GitHub token.'); 40 | }; 41 | -------------------------------------------------------------------------------- /src/lib/log.js: -------------------------------------------------------------------------------- 1 | const log = require('npmlog'); 2 | 3 | module.exports = function (level) { 4 | log.level = level; 5 | ['silly', 'verbose', 'info', 'http', 'warn', 'error'].forEach((level) => { 6 | log[level] = log[level].bind(log, 'semantic-release'); 7 | }); 8 | 9 | return log; 10 | }; 11 | -------------------------------------------------------------------------------- /src/lib/npm.js: -------------------------------------------------------------------------------- 1 | /* eslint require-atomic-updates: off */ 2 | 3 | const _ = require('lodash'); 4 | const inquirer = require('inquirer'); 5 | const npm = require('npm'); 6 | const profile = require('npm-profile'); 7 | const validator = require('validator'); 8 | const log = require('npmlog'); 9 | 10 | const DEFAULT_REGISTRY = 'https://registry.npmjs.org/'; 11 | 12 | async function getNpmToken({npm}) { 13 | let token; 14 | 15 | try { 16 | const result = await profile.loginCouch(npm.username.toLowerCase(), npm.password, {registry: npm.registry}); 17 | token = result.token; 18 | } catch (error) { 19 | if (error.code === 'EOTP') { 20 | await askForOTP(npm); 21 | token = npm.token; 22 | } 23 | } 24 | 25 | if (!token) throw new Error(`Could not login to npm.`); 26 | 27 | npm.token = token; 28 | log.info(`Successfully created npm token. ${npm.token}`); 29 | } 30 | 31 | function askForOTP(npm) { 32 | return inquirer.prompt({ 33 | type: 'input', 34 | name: 'otp', 35 | message: 'What is your NPM two-factor authentication code?', 36 | validate: (answer) => validateToken(answer, npm), 37 | }); 38 | } 39 | 40 | async function validateToken(otp, npm) { 41 | if (!validator.isNumeric(otp)) { 42 | return false; 43 | } 44 | 45 | try { 46 | const {token} = await profile.loginCouch(npm.username, npm.password, {registry: npm.registry, otp}); 47 | 48 | npm.token = token; 49 | 50 | return true; 51 | } catch (_) { 52 | // Invalid 2FA token 53 | } 54 | 55 | return 'Invalid authentication code'; 56 | } 57 | 58 | function getRegistry(pkg, conf) { 59 | if (pkg.publishConfig && pkg.publishConfig.registry) return pkg.publishConfig.registry; 60 | if (pkg.name[0] !== '@') return conf.get('registry') || DEFAULT_REGISTRY; 61 | 62 | const [scope] = pkg.name.split('/'); 63 | const scopedRegistry = conf.get(`${scope}/registry`); 64 | 65 | if (scopedRegistry) return scopedRegistry; 66 | 67 | return conf.get('registry') || DEFAULT_REGISTRY; 68 | } 69 | 70 | module.exports = async function (pkg, info) { 71 | info.npm = await inquirer.prompt([ 72 | { 73 | type: 'input', 74 | name: 'registry', 75 | message: 'What is your npm registry?', 76 | default: getRegistry(pkg, npm.config), 77 | validate: _.bind(validator.isURL, null, _, { 78 | protocols: ['http', 'https'], 79 | require_protocol: true, // eslint-disable-line camelcase 80 | require_tld: false, // eslint-disable-line camelcase 81 | }), 82 | }, 83 | { 84 | type: 'list', 85 | name: 'authmethod', 86 | message: 'Which authentication method is this npm registry using?', 87 | choices: [ 88 | {name: 'Token based', value: 'token'}, 89 | {name: 'Legacy (username, password, email)', value: 'legacy'}, 90 | ], 91 | default: 'token', 92 | when: (answers) => answers.registry !== DEFAULT_REGISTRY && !_.has(info.options, 'npm-token'), 93 | }, 94 | { 95 | type: 'input', 96 | name: 'username', 97 | message: 'What is your npm username?', 98 | default: info.options['npm-username'] || npm.config.get('username'), 99 | validate: _.ary(_.bind(validator.isLength, null, _, 1), 1), 100 | when: () => !_.has(info.options, 'npm-token'), 101 | }, 102 | { 103 | type: 'password', 104 | name: 'password', 105 | message: 'What is your npm password?', 106 | validate: _.ary(_.bind(validator.isLength, null, _, 1), 1), 107 | when: () => !_.has(info.options, 'npm-token'), 108 | }, 109 | { 110 | type: 'input', 111 | name: 'email', 112 | message: 'What is your npm email address?', 113 | default: info.options['npm-username'] || npm.config.get('init-author-email'), 114 | validate: _.ary(_.bind(validator.isLength, null, _, 1), 1), 115 | when: (answers) => answers.authmethod === 'legacy', 116 | }, 117 | ]); 118 | 119 | info.npm.authmethod = info.npm.authmethod || 'token'; 120 | 121 | if (_.has(info.options, 'npm-token')) { 122 | info.npm.token = info.options['npm-token']; 123 | log.info('Using npm token from command line argument.'); 124 | return; 125 | } 126 | 127 | if (info.npm.authmethod === 'token') { 128 | await getNpmToken(info); 129 | } 130 | }; 131 | -------------------------------------------------------------------------------- /src/lib/repository.js: -------------------------------------------------------------------------------- 1 | const {readFileSync} = require('fs'); 2 | const url = require('url'); 3 | const _ = require('lodash'); 4 | const ghUrl = require('github-url-from-git'); 5 | const ini = require('ini'); 6 | const inquirer = require('inquirer'); 7 | const parseGhUrl = require('parse-github-repo-url'); 8 | const request = require('request-promise').defaults({resolveWithFullResponse: true}); 9 | const validator = require('validator'); 10 | const log = require('npmlog'); 11 | 12 | /* eslint-disable node/no-deprecated-api */ 13 | 14 | function getRemoteUrl({repository}) { 15 | if (!repository || !repository.url) { 16 | const gitConfig = ini.decode(readFileSync('./.git/config', 'utf8')); 17 | const repo = gitConfig['remote "origin"'].url; 18 | if (!repo) throw new Error('No repository found.'); 19 | repository = {type: 'git', url: `${ghUrl(repo)}.git`}; 20 | } 21 | 22 | const parsed = url.parse(repository.url); 23 | parsed.auth = null; 24 | parsed.protocol = 'https'; 25 | repository.url = url.format(parsed); 26 | 27 | return repository.url; 28 | } 29 | 30 | module.exports = async function (pkg, info) { 31 | let repoUrl; 32 | try { 33 | repoUrl = await getRemoteUrl(pkg); 34 | } catch (error) { 35 | log.error('Could not get repository url. Please create/add the repository.'); 36 | throw error; 37 | } 38 | 39 | log.verbose(`Detected git url: ${repoUrl}`); 40 | info.giturl = repoUrl; 41 | const parsedUrl = parseGhUrl(repoUrl); 42 | 43 | if (!parsedUrl) { 44 | log.info('Not a reqular GitHub URL.'); 45 | const eurl = url.parse(repoUrl); 46 | delete eurl.pathname; 47 | delete eurl.search; 48 | delete eurl.query; 49 | delete eurl.hash; 50 | 51 | const answers = await inquirer.prompt([ 52 | { 53 | type: 'confirm', 54 | name: 'enterprise', 55 | message: 'Are you using GitHub Enterprise?', 56 | default: true, 57 | }, 58 | { 59 | type: 'input', 60 | name: 'url', 61 | message: 'What is your GitHub Enterprise url?', 62 | default: url.format(eurl), 63 | when: _.bind(_.get, null, _, 'enterprise'), 64 | validate: _.bind(validator.isURL, null, _, {protocols: ['http', 'https'], require_protocol: true}), // eslint-disable-line camelcase 65 | }, 66 | ]); 67 | info.ghepurl = answers.url; 68 | if (answers.enterprise) return; 69 | throw new Error(`GitHub repository URL is invalid: ${repoUrl}`); 70 | } 71 | 72 | info.ghrepo = {slug: parsedUrl}; 73 | 74 | try { 75 | await request.head(repoUrl); 76 | } catch (error /* eslint-disable-line no-unused-vars */) { 77 | const answers = await inquirer.prompt([ 78 | { 79 | type: 'confirm', 80 | name: 'private', 81 | message: 'Is the GitHub repository private?', 82 | default: false, 83 | }, 84 | ]); 85 | _.assign(info.ghrepo, answers); 86 | if (answers.private) return; 87 | throw new Error('Could not access GitHub repository: ' + repoUrl); 88 | } 89 | }; 90 | 91 | /* eslint-enable node/no-deprecated-api */ 92 | -------------------------------------------------------------------------------- /src/lib/travis.js: -------------------------------------------------------------------------------- 1 | const {readFileSync} = require('fs'); 2 | const {join} = require('path'); 3 | 4 | const _ = require('lodash'); 5 | const pify = require('pify'); 6 | const pRetry = require('p-retry'); 7 | const home = require('user-home'); 8 | const Travis = require('travis-ci'); 9 | const yaml = require('js-yaml'); 10 | const log = require('npmlog'); 11 | 12 | async function isSyncing(travis) { 13 | const response = await pify(travis.users.get.bind(travis))(); 14 | return _.get(response, 'user.is_syncing'); 15 | } 16 | 17 | async function syncTravis(travis) { 18 | try { 19 | await pify(travis.users.sync.post.bind(travis))(); 20 | } catch (error) { 21 | if (error.message !== 'Sync already in progress. Try again later.') throw error; 22 | } 23 | 24 | await pRetry(() => isSyncing(travis), {forever: true, minTimeout: 500, maxTimeout: 1000}); 25 | } 26 | 27 | async function setEnvVar(travis, name, value) { 28 | const request = pify(travis.agent.request.bind(travis.agent)); 29 | 30 | const response = await request('GET', `/settings/env_vars?repository_id=${travis.repoid}`); 31 | let envid = _.get(_.find(response.env_vars, ['name', name]), 'id'); 32 | envid = envid ? `/${envid}` : ''; 33 | 34 | await request( 35 | envid ? 'PATCH' : 'POST', 36 | `/settings/env_vars${envid}?repository_id=${travis.repoid}`, 37 | {env_var: {name, value, public: false}} // eslint-disable-line camelcase 38 | ); 39 | } 40 | 41 | async function setUpTravis(pkg, info) { 42 | const {travis} = info; 43 | 44 | log.info('Syncing repositories…'); 45 | await syncTravis(travis); 46 | 47 | const [githubOrg, repoName] = info.ghrepo.slug; 48 | 49 | try { 50 | travis.repoid = _.get(await pify(travis.repos(githubOrg, repoName).get.bind(travis))(), 'repo.id'); 51 | } catch (error) { 52 | if (error.file && error.file === 'not found') { 53 | throw new Error(`Unable to find repo id for "${info.giturl}" on Travis.`); 54 | } else { 55 | throw error; 56 | } 57 | } 58 | 59 | if (!travis.repoid) throw new Error('Could not get repo id'); 60 | 61 | const {result} = await pify(travis.hooks(travis.repoid).put.bind(travis))({ 62 | hook: {active: true}, 63 | }); 64 | if (!result) throw new Error('Could not enable hook on Travis CI'); 65 | log.info('Successfully created Travis CI hook.'); 66 | 67 | await setEnvVar(travis, 'GH_TOKEN', info.github.token); 68 | 69 | if (info.npm.authmethod === 'token') { 70 | await setEnvVar(travis, 'NPM_TOKEN', info.npm.token); 71 | } else { 72 | await setEnvVar(travis, 'NPM_USERNAME', info.npm.username); 73 | await setEnvVar(travis, 'NPM_PASSWORD', info.npm.password); 74 | await setEnvVar(travis, 'NPM_EMAIL', info.npm.email); 75 | } 76 | 77 | log.info('Successfully set environment variables on Travis CI.'); 78 | } 79 | 80 | module.exports = async function (endpoint, pkg, info) { 81 | const travisPath = join(home, '.travis/config.yml'); 82 | let token; 83 | 84 | try { 85 | const travisConfig = yaml.safeLoad(readFileSync(travisPath, 'utf8')); 86 | token = travisConfig.endpoints[`${endpoint}/`].access_token; 87 | } catch (_) { 88 | log.info('Could not load Travis CI config for endpoint.'); 89 | } 90 | 91 | const travis = new Travis({ 92 | version: '2.0.0', 93 | headers: { 94 | // Won't work with a different user-agent ¯\_(ツ)_/¯ 95 | 'User-Agent': 'Travis', 96 | }, 97 | }); 98 | 99 | info.travis = travis; 100 | info.endpoint = endpoint; 101 | 102 | travis.agent._endpoint = endpoint; 103 | 104 | if (token) { 105 | travis.agent.setAccessToken(token); 106 | } else { 107 | await pify(travis.authenticate.bind(travis))({github_token: info.github.token}); // eslint-disable-line camelcase 108 | } 109 | 110 | await setUpTravis(pkg, info); 111 | 112 | console.log( 113 | 'Please refer to https://github.com/semantic-release/semantic-release/blob/master/docs/recipes/travis.md to configure your .travis.yml file.' 114 | ); 115 | }; 116 | -------------------------------------------------------------------------------- /test/cli.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import cli from '../src'; 3 | 4 | test('no tests yet', (t) => { 5 | t.truthy(cli); 6 | }); 7 | --------------------------------------------------------------------------------