├── .eslintignore ├── .prettierrc ├── .gitignore ├── .editorconfig ├── lib ├── providers.js ├── includes │ └── writeToFile.js ├── changelog.js ├── providers │ ├── node.js │ └── csharp.js ├── releasy.js └── steps.js ├── test ├── fixtures │ ├── testversionnull.json │ └── testpackage.json ├── utils.js ├── otp.test.js ├── node-provider.test.js ├── changelog.test.js ├── releasy.test.js ├── steps.test.js └── csharp-provider.test.js ├── .eslintrc ├── .github ├── PULL_REQUEST_TEMPLATE.md ├── workflows │ └── ci.yml └── ISSUE_TEMPLATE.md ├── package.json ├── bin └── releasy.js ├── CHANGELOG.md └── README.md /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | "@vtex/prettier-config" 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .idea/ 3 | npm-debug.log 4 | package-lock.json 5 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | insert_final_newline = true 7 | indent_style = space 8 | indent_size = 2 -------------------------------------------------------------------------------- /lib/providers.js: -------------------------------------------------------------------------------- 1 | const nodeProvider = require('./providers/node') 2 | const csharpProvider = require('./providers/csharp') 3 | 4 | module.exports = [nodeProvider, csharpProvider] 5 | -------------------------------------------------------------------------------- /lib/includes/writeToFile.js: -------------------------------------------------------------------------------- 1 | const { writeFileSync } = require('fs') 2 | 3 | module.exports = (path, content) => 4 | writeFileSync(path, content, { 5 | encoding: 'UTF-8', 6 | }) 7 | -------------------------------------------------------------------------------- /test/fixtures/testversionnull.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "releasy", 3 | "version": null, 4 | "description": "CLI tool to release node applications with tag and auto semver bump" 5 | } 6 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "vtex", 3 | "env": { 4 | "es6": true, 5 | "jest": true, 6 | "node": true 7 | }, 8 | "rules": { 9 | "global-require": "off", 10 | "no-console": "off" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /test/utils.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | 3 | const writeToFile = require('../lib/includes/writeToFile.js') 4 | 5 | export const createPackageJson = (filePath, pkg) => 6 | writeToFile(path.resolve(`./${filePath}`), JSON.stringify(pkg)) 7 | 8 | export const MANIFEST = { 9 | vendor: 'test', 10 | name: 'releasy', 11 | version: '0.3.0', 12 | description: 13 | 'CLI tool to release node applications with tag and auto semver bump', 14 | scripts: { 15 | prereleasy: 'echo pre', 16 | postreleasy: 'echo post', 17 | }, 18 | } 19 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | #### What is the purpose of this pull request? 2 | 3 | 4 | #### What problem is this solving? 5 | 6 | 7 | #### How should this be manually tested? 8 | 9 | #### Screenshots or example usage 10 | 11 | #### Types of changes 12 | - [ ] Bug fix (a non-breaking change which fixes an issue) 13 | - [ ] New feature (a non-breaking change which adds functionality) 14 | - [ ] Breaking change (fix or feature that would cause existing functionality to change) 15 | - [ ] Requires change to documentation, which has been updated accordingly. 16 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | pull_request: 4 | types: [opened, synchronize] 5 | push: 6 | branches: [main] 7 | 8 | jobs: 9 | lint: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v1 14 | - name: NodeJS 12.x 15 | uses: actions/setup-node@v1 16 | with: 17 | node-version: 12.x 18 | - name: Install dependencies 19 | run: yarn 20 | - run: yarn lint 21 | 22 | test: 23 | runs-on: ubuntu-latest 24 | 25 | steps: 26 | - uses: actions/checkout@v1 27 | - name: NodeJS 12.x 28 | uses: actions/setup-node@v1 29 | with: 30 | node-version: 12.x 31 | - name: Install dependencies 32 | run: yarn 33 | - run: yarn test 34 | -------------------------------------------------------------------------------- /test/fixtures/testpackage.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "releasy", 3 | "version": "1.0.0", 4 | "description": "CLI tool to release node applications with tag and auto semver bump", 5 | "main": "releasy.js", 6 | "bin": "releasy.js", 7 | "scripts": { 8 | "test": "make test", 9 | "prereleasy": "echo pre", 10 | "postreleasy": "echo post" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "git://github.com/vtex/releasy.git" 15 | }, 16 | "keywords": [ 17 | "CLI", 18 | "releasy", 19 | "release", 20 | "semver" 21 | ], 22 | "author": "Guiherme Rodrigues", 23 | "license": "MIT", 24 | "bugs": { 25 | "url": "https://github.com/vtex/releasy/issues" 26 | }, 27 | "homepage": "https://github.com/vtex/releasy", 28 | "dependencies": { 29 | "commander": "~2.1.0", 30 | "q": "~0.9.7", 31 | "prompt": "~0.2.12", 32 | "semver": "~2.2.1" 33 | }, 34 | "preferGlobal": true, 35 | "devDependencies": { 36 | "should": "~2.1.1" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /lib/changelog.js: -------------------------------------------------------------------------------- 1 | const addChangelogVersionLinks = (config, changelogContent) => { 2 | let updatedChangelog = changelogContent 3 | 4 | const [org, repo] = config.githubInfo 5 | 6 | const unreleasedUrl = `https://github.com/${org}/${repo}/compare/${config.tagName}...HEAD` 7 | const unreleasedLink = `[${config.unreleasedRaw}]: ${unreleasedUrl}` 8 | 9 | if (changelogContent.includes(`[${config.unreleasedRaw}]:`)) { 10 | updatedChangelog = updatedChangelog.replace( 11 | new RegExp(`\\[${config.unreleasedRaw}\\]:.*HEAD`), 12 | unreleasedLink 13 | ) 14 | } else { 15 | updatedChangelog = `${updatedChangelog}\n\n${unreleasedLink}` 16 | } 17 | 18 | const currentVersionLink = `[${config.currentVersion}]:` 19 | const releaseUrl = `https://github.com/${org}/${repo}/compare/${config.currentVersionTagName}...${config.tagName}` 20 | const releaseLink = `[${config.newVersion}]: ${releaseUrl}` 21 | 22 | if (updatedChangelog.includes(currentVersionLink)) { 23 | return updatedChangelog.replace( 24 | currentVersionLink, 25 | `${releaseLink}\n${currentVersionLink}` 26 | ) 27 | } 28 | 29 | return `${updatedChangelog}\n${releaseLink}` 30 | } 31 | 32 | module.exports = { 33 | addChangelogVersionLinks, 34 | } 35 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Expected Behavior 4 | 5 | 6 | 7 | ## Current Behavior 8 | 9 | 10 | 11 | ## Possible Solution 12 | 13 | 14 | 15 | ## Steps to Reproduce (for bugs) 16 | 17 | 18 | 1. 19 | 2. 20 | 3. 21 | 4. 22 | 23 | ## Context 24 | 25 | 26 | 27 | ## Your Environment 28 | 29 | * Version used: 30 | * Environment name and version (e.g. Chrome 39, node.js 5.4): 31 | * Operating System and version (desktop or mobile): 32 | * Link to your project: 33 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "releasy", 3 | "version": "1.13.1", 4 | "description": "CLI tool to release node applications with tag and auto semver bump", 5 | "main": "lib/releasy.js", 6 | "bin": "bin/releasy.js", 7 | "scripts": { 8 | "test": "vtex-test-tools test", 9 | "lint": "eslint ." 10 | }, 11 | "repository": { 12 | "type": "git", 13 | "url": "git://github.com/vtex/releasy.git" 14 | }, 15 | "keywords": [ 16 | "CLI", 17 | "releasy", 18 | "release", 19 | "semver" 20 | ], 21 | "author": "Guiherme Rodrigues", 22 | "license": "MIT", 23 | "bugs": { 24 | "url": "https://github.com/vtex/releasy/issues" 25 | }, 26 | "homepage": "https://github.com/vtex/releasy", 27 | "husky": { 28 | "hooks": { 29 | "pre-commit": "lint-staged", 30 | "pre-push": "yarn lint && yarn test" 31 | } 32 | }, 33 | "lint-staged": { 34 | "*.{ts,js,tsx,jsx}": [ 35 | "eslint --fix", 36 | "prettier --write" 37 | ], 38 | "*.json": [ 39 | "prettier --write" 40 | ] 41 | }, 42 | "dependencies": { 43 | "camelcase": "^5.3.1", 44 | "chalk": "^2.4.2", 45 | "commander": "~3.0.2", 46 | "github-api": "^3.3.0", 47 | "js-yaml": "^3.13.1", 48 | "prompt": "^1.2.0", 49 | "semver": "^6.3.0" 50 | }, 51 | "devDependencies": { 52 | "@types/jest": "^26.0.9", 53 | "@vtex/prettier-config": "^1.0.0", 54 | "@vtex/test-tools": "^3.3.0", 55 | "eslint": "^7.6.0", 56 | "eslint-config-vtex": "^14.1.2", 57 | "husky": "^4.2.5", 58 | "lint-staged": "^10.2.11", 59 | "mock-fs": "^5.1.2", 60 | "prettier": "^2.5.1", 61 | "typescript": "^3.9.7" 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /bin/releasy.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | const program = require('commander') 3 | const camelCase = require('camelcase') 4 | 5 | const releasy = require('../lib/releasy') 6 | const pkg = require('../package.json') 7 | const steps = require('../lib/steps') 8 | 9 | const optionsFile = steps.getOptionsFile() 10 | 11 | let type = optionsFile.type || 'patch' 12 | let args = process.argv 13 | 14 | if ( 15 | ['major', 'minor', 'patch', 'promote', 'prerelease', 'pre'].indexOf( 16 | args[2] 17 | ) !== -1 18 | ) { 19 | type = args[2] 20 | if (type === 'pre') type = 'prerelease' 21 | console.log('Release:', type) 22 | 23 | args = args.slice(0, 2).concat(args.slice(3)) 24 | } 25 | 26 | program 27 | .version(pkg.version) 28 | .usage('(major|minor|*patch*|prerelease) [options]') 29 | .option('-f, --filename [path]', 'Your package manifest file', 'package.json') 30 | .option('-t, --tag-name [tag]', 'The prerelease tag in your version', 'beta') 31 | .option('--npm-tag [tag]', 'Tag option for npm publish', '') 32 | .option('-f, --folder [folder]', 'Folder option for npm publish', '') 33 | .option('--otp [code]', 'One-time password code for npm publish') 34 | .option('--stable', 'Mark this as a relese stable (no prerelease tag)', false) 35 | .option('--no-commit', 'Do not commit the version change', false) 36 | .option('--no-tag', 'Do not tag the version change', false) 37 | .option('--no-push', 'Do not push changes to remote', false) 38 | .option( 39 | '--display-name', 40 | 'Add the project name to the tag and release commit', 41 | false 42 | ) 43 | .option( 44 | '--notes', 45 | 'Publish notes to GitHub Release Notes. Personal Token is required to use this option', 46 | false 47 | ) 48 | .option('-n, --npm', 'Publish to npm', false) 49 | .option( 50 | '-d, --dry-run', 51 | 'Dont do anything, just show what would be done', 52 | false 53 | ) 54 | .option('-s, --silent', 'Dont ask for confirmation', false) 55 | .option('-q, --quiet', "Don't write messages to console", false) 56 | .parse(args) 57 | 58 | for (let [key, value] of Object.entries(optionsFile)) { 59 | if (key.startsWith('no-')) { 60 | key = key.slice(3, key.length) 61 | value = !value 62 | } 63 | 64 | program[camelCase(key)] = value 65 | } 66 | 67 | program.type = type 68 | program.cli = true 69 | 70 | releasy(program) 71 | -------------------------------------------------------------------------------- /lib/providers/node.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | 3 | const semver = require('semver') 4 | 5 | const writeToFile = require('../includes/writeToFile') 6 | 7 | const checkVersionFiles = (filePath) => { 8 | const packagePath = filePath 9 | const manifestPath = filePath.replace(/(.*\/)?(.*)(.json)$/, '$1manifest$3') 10 | const hasManifest = fs.existsSync(manifestPath) 11 | const filesPath = [] 12 | 13 | try { 14 | const pkg = JSON.parse(fs.readFileSync(packagePath)) 15 | 16 | if (pkg.version !== null && !pkg.private) filesPath.push(packagePath) 17 | if (hasManifest) filesPath.push(manifestPath) 18 | } catch (e) { 19 | if (!hasManifest) { 20 | console.error(e.message) 21 | throw new Error(`Version file not found: ${filePath}`) 22 | } 23 | 24 | filesPath.push(manifestPath) 25 | } 26 | 27 | return filesPath 28 | } 29 | 30 | module.exports = function (filePath) { 31 | this.filePath = checkVersionFiles(filePath) 32 | 33 | this.readVersion = () => { 34 | const pkg = JSON.parse(fs.readFileSync(this.filePath[0])) 35 | 36 | if (!pkg.version) 37 | throw new Error( 38 | `Your package.json file is missing the version property. If you don't need it, you can add 'version: 0.0.0' and we gonna use the version on the manifest instead!` 39 | ) 40 | const pkgVersion = semver(pkg.version, true) 41 | 42 | // When filePath has two files, return higher version 43 | if (this.filePath.length === 2) { 44 | const mnft = JSON.parse(fs.readFileSync(this.filePath[1])) 45 | const mnftVersion = semver(mnft.version, true) 46 | 47 | return semver.lt(pkgVersion.format(), mnftVersion.format()) 48 | ? mnftVersion 49 | : pkgVersion 50 | } 51 | 52 | return pkgVersion 53 | } 54 | 55 | this.readName = () => { 56 | let name = null 57 | let vendor = null 58 | 59 | // When filePath has two files, return the name from manifest with vendor if exists 60 | if (this.filePath.length === 2) { 61 | this.filePath.forEach((path) => { 62 | if (!path.includes('manifest.json')) return 63 | const manifest = JSON.parse(fs.readFileSync(path)) 64 | 65 | name = manifest.name 66 | vendor = manifest.vendor 67 | }) 68 | } else { 69 | const pkg = JSON.parse(fs.readFileSync(this.filePath[0])) 70 | 71 | name = pkg.name 72 | vendor = pkg.vendor 73 | } 74 | 75 | if (!name) 76 | throw new Error('Your versioning file is missing the name property.') 77 | 78 | return name && vendor ? `${vendor}.${name}` : name 79 | } 80 | 81 | this.writeVersion = (newVersion) => { 82 | for (const i in this.filePath) { 83 | const pkg = JSON.parse(fs.readFileSync(this.filePath[i])) 84 | 85 | pkg.version = newVersion.format ? newVersion.format() : newVersion 86 | 87 | const pkgJson = `${JSON.stringify(pkg, null, 2)}\n` 88 | 89 | writeToFile(this.filePath[i], pkgJson) 90 | } 91 | } 92 | 93 | this.getScript = (script) => { 94 | let cmd 95 | 96 | for (const f in this.filePath) { 97 | const { scripts } = JSON.parse(fs.readFileSync(this.filePath[f])) 98 | 99 | cmd = scripts && scripts[script] ? scripts[script] : null 100 | } 101 | 102 | return cmd 103 | } 104 | } 105 | 106 | module.exports.supports = function (filePath) { 107 | return /\.json$/.test(filePath) 108 | } 109 | -------------------------------------------------------------------------------- /lib/providers/csharp.js: -------------------------------------------------------------------------------- 1 | const { EOL } = require('os') 2 | const fs = require('fs') 3 | 4 | const semver = require('semver') 5 | 6 | const writeToFile = require('../includes/writeToFile') 7 | 8 | module.exports = function (filePath) { 9 | if (!fs.existsSync(filePath)) { 10 | throw new Error(`Version file not found: ${filePath}`) 11 | } 12 | 13 | this.filePath = filePath 14 | 15 | function getRegexFor(attributeName) { 16 | return new RegExp( 17 | `\\[assembly:\\s*${attributeName}\\s*\\(\\s*"(.+)"\\s*\\)\\s*\\]`, 18 | 'g' 19 | ) 20 | } 21 | 22 | function getPredominantLineEnding(text) { 23 | let crlfs = text.match(/\r\n/g) 24 | 25 | crlfs = crlfs ? crlfs.length : 0 26 | 27 | let lfs = text.match(/n/g) 28 | 29 | lfs = lfs ? lfs.length : 0 30 | lfs -= crlfs 31 | 32 | if (crlfs === lfs) return EOL 33 | if (lfs > crlfs) return '\n' 34 | 35 | return '\r\n' 36 | } 37 | 38 | function replaceOrAppend(text, attributeName, replacement) { 39 | const pattern = getRegexFor(attributeName) 40 | 41 | return pattern.test(text) 42 | ? text.replace(pattern, replacement) 43 | : appendAttribute(text, replacement) 44 | } 45 | 46 | function appendAttribute(text, attribute) { 47 | const lineEnding = getPredominantLineEnding(text) 48 | 49 | if (text[text.length - 1] === '\n') return text + attribute + lineEnding 50 | 51 | return text + lineEnding + attribute + lineEnding 52 | } 53 | 54 | this.readVersion = function () { 55 | const assemblyInfo = fs.readFileSync(this.filePath).toString() 56 | let versionMatch = getRegexFor('AssemblyInformationalVersion').exec( 57 | assemblyInfo 58 | ) 59 | 60 | if (versionMatch === null) 61 | versionMatch = getRegexFor('AssemblyFileVersion').exec(assemblyInfo) 62 | if (versionMatch === null) 63 | versionMatch = getRegexFor('AssemblyVersion').exec(assemblyInfo) 64 | if (versionMatch === null) 65 | throw new Error( 66 | `Could not find version information in file ${this.filePath}` 67 | ) 68 | 69 | return semver(versionMatch[1]) 70 | } 71 | 72 | this.readName = () => { 73 | throw new Error( 74 | 'Adding name was not implemented for C#... But PR are always welcome :/' 75 | ) 76 | } 77 | 78 | this.writeVersion = function (newVersion) { 79 | newVersion = newVersion.format ? newVersion.format() : newVersion 80 | const indexOfPrerelease = newVersion.indexOf('-') 81 | const versionWithoutPrerelease = 82 | indexOfPrerelease > 0 83 | ? newVersion.substr(0, indexOfPrerelease) 84 | : newVersion 85 | 86 | let assemblyInfo = fs.readFileSync(this.filePath).toString() 87 | 88 | assemblyInfo = replaceOrAppend( 89 | assemblyInfo, 90 | 'AssemblyVersion', 91 | `[assembly: AssemblyVersion("${versionWithoutPrerelease}")]` 92 | ) 93 | 94 | assemblyInfo = replaceOrAppend( 95 | assemblyInfo, 96 | 'AssemblyFileVersion', 97 | `[assembly: AssemblyFileVersion("${versionWithoutPrerelease}")]` 98 | ) 99 | 100 | assemblyInfo = replaceOrAppend( 101 | assemblyInfo, 102 | 'AssemblyInformationalVersion', 103 | `[assembly: AssemblyInformationalVersion("${newVersion}")]` 104 | ) 105 | 106 | writeToFile(this.filePath, assemblyInfo) 107 | } 108 | } 109 | 110 | module.exports.supports = function (filePath) { 111 | return /\.cs$/.test(filePath) 112 | } 113 | -------------------------------------------------------------------------------- /test/otp.test.js: -------------------------------------------------------------------------------- 1 | const prompt = require('prompt') 2 | const mockFs = require('mock-fs') 3 | 4 | const releasy = require('../lib/releasy') 5 | const steps = require('../lib/steps') 6 | 7 | describe('npm OTP', () => { 8 | beforeEach(() => { 9 | mockFs({ 10 | 'package.json': JSON.stringify({ 11 | version: '0.0.0', 12 | }), 13 | }) 14 | }) 15 | 16 | afterEach(() => { 17 | jest.restoreAllMocks() 18 | mockFs.restore() 19 | }) 20 | 21 | it('should ask for a otp if 2FA is enabled', async () => { 22 | jest.spyOn(steps, 'status').mockReturnValue('nothing to commit') 23 | jest.spyOn(prompt, 'get').mockImplementationOnce((schema, callback) => { 24 | callback(null, { [schema.name]: '123456' }) 25 | }) 26 | 27 | const runSpy = jest.spyOn(steps, 'run').mockImplementation((cmd) => { 28 | if (cmd === 'npm publish') { 29 | // Pretend user has 2FA active 30 | return Promise.reject(new Error('npm ERR! code EOTP')) 31 | } 32 | 33 | return 'ok' 34 | }) 35 | 36 | await releasy({ 37 | filename: 'package.json', 38 | type: 'minor', 39 | npm: true, 40 | }) 41 | 42 | expect(runSpy).toHaveBeenCalledTimes(2) 43 | expect(runSpy).toHaveBeenLastCalledWith( 44 | 'npm publish --otp 123456', 45 | expect.anything(), 46 | undefined, 47 | true 48 | ) 49 | mockFs.restore() 50 | }) 51 | 52 | it('should cancel publish if no otp is provided', async () => { 53 | jest.spyOn(steps, 'status').mockReturnValue('nothing to commit') 54 | jest.spyOn(prompt, 'get').mockImplementationOnce((schema, callback) => { 55 | callback(null, { [schema.name]: '' }) 56 | }) 57 | const publishSpy = jest.spyOn(steps, 'publish') 58 | const runSpy = jest.spyOn(steps, 'run').mockImplementation((cmd) => { 59 | if (cmd === 'npm publish') { 60 | // Pretend user has 2FA active 61 | return Promise.reject(new Error('npm ERR! code EOTP')) 62 | } 63 | 64 | return 'ok' 65 | }) 66 | 67 | await releasy({ 68 | filename: 'package.json', 69 | type: 'minor', 70 | npm: true, 71 | }) 72 | 73 | expect(publishSpy).toHaveBeenCalledTimes(1) 74 | await expect(publishSpy.mock.results[0].value).rejects.toBe( 75 | 'Cancelled by user' 76 | ) 77 | expect(runSpy).toHaveBeenCalledTimes(1) 78 | expect(runSpy).toHaveBeenLastCalledWith( 79 | 'npm publish', 80 | expect.anything(), 81 | undefined, 82 | true 83 | ) 84 | mockFs.restore() 85 | }) 86 | 87 | it('should fail to publish if incorrect otp is passed via CLI options', async () => { 88 | jest.spyOn(steps, 'status').mockReturnValue('nothing to commit') 89 | 90 | const publishSpy = jest.spyOn(steps, 'publish') 91 | const runSpy = jest.spyOn(steps, 'run').mockImplementation((cmd) => { 92 | if (cmd === 'npm publish --otp 123456') { 93 | return Promise.reject(new Error('npm ERR! code EOTP')) 94 | } 95 | 96 | return 'ok' 97 | }) 98 | 99 | await releasy({ 100 | filename: 'package.json', 101 | type: 'minor', 102 | npm: true, 103 | otp: '123456', 104 | }) 105 | 106 | expect(publishSpy).toHaveReturnedTimes(1) 107 | await expect(publishSpy.mock.results[0].value).rejects.toThrow( 108 | 'OTP code is incorrect or expired and you have runned out of attemps' 109 | ) 110 | expect(runSpy).toHaveBeenCalledTimes(1) 111 | expect(runSpy).toHaveBeenLastCalledWith( 112 | 'npm publish --otp 123456', 113 | expect.anything(), 114 | undefined, 115 | true 116 | ) 117 | mockFs.restore() 118 | }) 119 | }) 120 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) 6 | and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). 7 | 8 | ## [Unreleased] 9 | 10 | ## [1.13.1] - 2022-01-11 11 | ### Changed 12 | - Removed `shelljs` dependency. 13 | 14 | ## [1.13.0] - 2022-01-04 15 | ### Added 16 | - Support for links in changelog files. 17 | 18 | ## [1.12.1] - 2022-01-03 19 | 20 | ## [1.12.0] - 2020-11-04 21 | ### Added 22 | - `display-name` option to the release message. 23 | 24 | ## [1.11.1] - 2020-08-26 25 | ### Fixed 26 | - `camelcase` package require. 27 | 28 | ## [1.11.0] - 2020-08-12 29 | ### Added 30 | - Flag `--otp` to handle NPM two-factor authentication. 31 | 32 | ### Fixed 33 | - Error when trying to push an inexistent tag (with `releasy --no-tag`). 34 | 35 | ## [1.10.5] - 2020-08-10 36 | ### Changed 37 | - Remove `q` promise library in favor of native `Promise` object. 38 | 39 | ## [1.10.4] - 2020-08-10 40 | ### Fixed 41 | - Colored messages printed as `undefined`. 42 | 43 | ## [1.10.3] - 2020-08-10 44 | 45 | ## [1.10.2] - 2019-10-08 46 | 47 | ### Fixed 48 | 49 | - `--no-...` flags and other flag default values. 50 | 51 | ## [1.10.1] - 2018-11-27 52 | 53 | ### Fixed 54 | 55 | - Check if there is a version on the `package.json` 56 | 57 | ## [1.10.0] - 2018-07-09 58 | 59 | ### Added 60 | 61 | - Bump version of both version files when exists. 62 | 63 | ### Changed 64 | 65 | - Refact eslint issues. 66 | - Make days and months always two-digit numbers. 67 | 68 | ### Fixed 69 | 70 | - Only push the tag that's was created. 71 | 72 | ## [1.9.1] - 2018-6-19 73 | 74 | ### Changed 75 | 76 | - Change `getGitHubRepo` to avoid the usage of `grep` and `cut` commands. 77 | 78 | ## [1.9.0] - 2018-6-18 79 | 80 | ### Added 81 | 82 | - Add a `--notes` options to post release notes on GitHub. 83 | 84 | ## [1.8.2] - 2018-6-6 85 | 86 | ### Added 87 | 88 | - Better explanation for the `CHANGELOG` workflow 89 | 90 | ### Fixed 91 | 92 | - Fix parse to get repo info when clone is via SSH. 93 | 94 | ## [1.8.1] - 2018-6-4 95 | 96 | ### Fixed 97 | 98 | - Move `github-api` lib to dependencies instead of devDependencies. 99 | 100 | ## [1.8.0] - 2018-6-1 101 | 102 | ### Added 103 | 104 | - Update `CHANGELOG.md` when exists and post a Release Notes in github repository 105 | 106 | [Unreleased]: https://github.com/vtex/releasy/compare/v1.13.1...HEAD 107 | [1.13.1]: https://github.com/vtex/releasy/compare/v1.13.0...v1.13.1 108 | [1.13.0]: https://github.com/vtex/releasy/compare/v1.12.1...v1.13.0 109 | [1.12.1]: https://github.com/vtex/releasy/compare/v1.12.0...v1.12.1 110 | [1.12.0]: https://github.com/vtex/releasy/compare/v1.11.1...v1.12.0 111 | [1.11.1]: https://github.com/vtex/releasy/compare/v1.11.0...v1.11.1 112 | [1.11.0]: https://github.com/vtex/releasy/compare/v1.10.5...v1.11.0 113 | [1.10.5]: https://github.com/vtex/releasy/compare/v1.10.4...v1.10.5 114 | [1.10.4]: https://github.com/vtex/releasy/compare/v1.10.3...v1.10.4 115 | [1.10.3]: https://github.com/vtex/releasy/compare/v1.10.2...v1.10.3 116 | [1.10.2]: https://github.com/vtex/releasy/compare/v1.10.1...v1.10.2 117 | [1.10.1]: https://github.com/vtex/releasy/compare/v1.10.0...v1.10.1 118 | [1.10.0]: https://github.com/vtex/releasy/compare/v1.9.1...v1.10.0 119 | [1.9.1]: https://github.com/vtex/releasy/compare/v1.9.0...v1.9.1 120 | [1.9.0]: https://github.com/vtex/releasy/compare/v1.8.2...v1.9.0 121 | [1.8.2]: https://github.com/vtex/releasy/compare/v1.8.1...v1.8.2 122 | [1.8.1]: https://github.com/vtex/releasy/compare/v1.8.0...v1.8.1 123 | [1.8.0]: https://github.com/vtex/releasy/compare/v1.7.3...v1.8.0 124 | -------------------------------------------------------------------------------- /test/node-provider.test.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const fs = require('fs') 3 | 4 | const semver = require('semver') 5 | 6 | const { createPackageJson } = require('./utils.js') 7 | const NodeVersionProvider = require('../lib/providers/node.js') 8 | 9 | const CUSTOM_MANIFEST_PATH = 'test/fixtures/manifest.json' 10 | 11 | describe('NodeVersionProvider', () => { 12 | afterEach(() => { 13 | if (fs.existsSync('test/fixtures/package.json')) 14 | fs.unlinkSync(path.resolve('test/fixtures/package.json')) 15 | 16 | if (fs.existsSync(CUSTOM_MANIFEST_PATH)) 17 | fs.unlinkSync(path.resolve(CUSTOM_MANIFEST_PATH)) 18 | }) 19 | 20 | describe('reading node version', () => { 21 | it('should return SemVer object', () => { 22 | // arrange 23 | createPackageJson('test/fixtures/package.json', { 24 | version: '1.2.3-beta.4', 25 | }) 26 | const provider = new NodeVersionProvider('test/fixtures/package.json') 27 | 28 | // act 29 | const version = provider.readVersion() 30 | 31 | // assert 32 | expect(version.format()).toBe('1.2.3-beta.4') 33 | }) 34 | 35 | it('should throw error for version in incorrect format', () => { 36 | // arrange 37 | createPackageJson('test/fixtures/package.json', { version: 'blabla' }) 38 | const provider = new NodeVersionProvider('test/fixtures/package.json') 39 | 40 | // act & assert 41 | expect(() => provider.readVersion()).toThrow() 42 | }) 43 | }) 44 | 45 | describe('writing node version', () => { 46 | it('should accept SemVer object', () => { 47 | // arrange 48 | createPackageJson('test/fixtures/package.json', { version: '0.1.0' }) 49 | const provider = new NodeVersionProvider('test/fixtures/package.json') 50 | const newVersion = semver('0.2.0') 51 | 52 | // act 53 | provider.writeVersion(newVersion) 54 | 55 | // assert 56 | expect( 57 | JSON.parse(fs.readFileSync('test/fixtures/package.json')).version 58 | ).toBe('0.2.0') 59 | }) 60 | 61 | it('should accept string version', () => { 62 | // arrange 63 | createPackageJson('test/fixtures/package.json', { version: '0.1.0' }) 64 | const provider = new NodeVersionProvider('test/fixtures/package.json') 65 | 66 | // act 67 | provider.writeVersion('0.3.0') 68 | 69 | // assert 70 | expect( 71 | JSON.parse(fs.readFileSync('test/fixtures/package.json')).version 72 | ).toBe('0.3.0') 73 | }) 74 | }) 75 | 76 | describe('file support', () => { 77 | it('should support .json extensions', () => { 78 | // act 79 | const supports = NodeVersionProvider.supports('mypackage.json') 80 | 81 | // assert 82 | expect(supports).toBe(true) 83 | }) 84 | 85 | it('should not support any other extension', () => { 86 | // act 87 | const supports = NodeVersionProvider.supports('arbitrary.extension') 88 | 89 | // assert 90 | expect(supports).toBe(false) 91 | }) 92 | }) 93 | 94 | describe('reading node name', () => { 95 | it('should return name without vendor', () => { 96 | const name = 'TestName' 97 | 98 | // arrange 99 | createPackageJson(CUSTOM_MANIFEST_PATH, { 100 | name, 101 | version: '1.2.3-beta.4', 102 | }) 103 | const provider = new NodeVersionProvider(CUSTOM_MANIFEST_PATH) 104 | 105 | // act 106 | const resultName = provider.readName() 107 | 108 | // assert 109 | expect(resultName).toBe(name) 110 | }) 111 | 112 | it('should return name with vendor', () => { 113 | const name = 'TestName' 114 | const vendor = 'test' 115 | const filePath = 'test/fixtures/manifest.json' 116 | 117 | // arrange 118 | createPackageJson(filePath, { 119 | vendor, 120 | name, 121 | version: '1.2.3-beta.4', 122 | }) 123 | const provider = new NodeVersionProvider(filePath) 124 | 125 | // act 126 | const resultName = provider.readName() 127 | 128 | // assert 129 | expect(resultName).toBe(`${vendor}.${name}`) 130 | }) 131 | 132 | it('should throw error name', () => { 133 | // arrange 134 | createPackageJson('test/fixtures/package.json', { 135 | version: '1.2.3-beta.4', 136 | }) 137 | const provider = new NodeVersionProvider('test/fixtures/package.json') 138 | 139 | // act & assert 140 | expect(() => provider.readName()).toThrow() 141 | }) 142 | }) 143 | }) 144 | -------------------------------------------------------------------------------- /test/changelog.test.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs').promises 2 | const cp = require('child_process') 3 | 4 | const mockFs = require('mock-fs') 5 | 6 | const releasy = require('../lib/releasy') 7 | const steps = require('../lib/steps') 8 | 9 | const defaultOptions = { 10 | filename: 'package.json', 11 | type: 'minor', 12 | commit: false, 13 | tag: false, 14 | push: false, 15 | npm: false, 16 | } 17 | 18 | jest.mock('child_process') 19 | 20 | jest.spyOn(cp, 'execSync').mockImplementation((cmd, opts) => { 21 | if (cmd === 'git remote get-url origin') { 22 | return Buffer.from('https://github.com/my-org/my-repo.git', 'utf-8') 23 | } 24 | 25 | return cp.execSync(cmd, opts) 26 | }) 27 | 28 | describe('Changelog', () => { 29 | describe('with default changelog', () => { 30 | beforeEach(() => { 31 | mockFs({ 32 | 'package.json': JSON.stringify({ 33 | name: 'my-package', 34 | version: '0.1.0', 35 | }), 36 | 'CHANGELOG.md': ` 37 | ## [Unreleased] 38 | ### Changed 39 | - Updated feature 1 to support use-case B. 40 | 41 | ## [0.1.0] - 2020-10-10 42 | ### Added 43 | - Feature 1 44 | `, 45 | }) 46 | }) 47 | 48 | afterEach(() => { 49 | mockFs.restore() 50 | jest.restoreAllMocks() 51 | }) 52 | 53 | it('should add new version on changelog after release', async () => { 54 | jest 55 | .spyOn(steps, 'preReleasy') 56 | .mockImplementation(() => Promise.resolve()) 57 | 58 | await releasy(defaultOptions) 59 | 60 | const packageContent = JSON.parse( 61 | (await fs.readFile('./package.json')).toString() 62 | ) 63 | 64 | const changelogContent = (await fs.readFile('./CHANGELOG.md')).toString() 65 | 66 | expect(packageContent.version).toBe('0.2.0') 67 | expect(changelogContent).toMatch(/\[0\.2\.0\]/) 68 | expect(changelogContent).toMatch('## [Unreleased]') 69 | }) 70 | 71 | it('should add link to unreleased changes and new tag', async () => { 72 | jest 73 | .spyOn(steps, 'preReleasy') 74 | .mockImplementation(() => Promise.resolve()) 75 | 76 | await releasy(defaultOptions) 77 | 78 | const changelogContent = (await fs.readFile('./CHANGELOG.md')).toString() 79 | 80 | expect(changelogContent).toMatch( 81 | `[0.2.0]: https://github.com/my-org/my-repo/compare/v0.1.0...v0.2.0` 82 | ) 83 | }) 84 | }) 85 | 86 | describe('with empty changelog', () => { 87 | beforeEach(() => { 88 | mockFs({ 89 | 'package.json': JSON.stringify({ name: 'my-app', version: '0.0.0' }), 90 | 'CHANGELOG.md': ` 91 | ## [Unreleased] 92 | ### Added 93 | - My first feature 94 | `, 95 | }) 96 | }) 97 | 98 | afterEach(() => { 99 | mockFs.restore() 100 | jest.restoreAllMocks() 101 | }) 102 | 103 | it('should create one section for newly released tag', async () => { 104 | jest 105 | .spyOn(steps, 'preReleasy') 106 | .mockImplementation(() => Promise.resolve()) 107 | 108 | await releasy(defaultOptions) 109 | 110 | const changelogContent = (await fs.readFile('./CHANGELOG.md')).toString() 111 | 112 | const [year, month, day] = new Date() 113 | .toISOString() 114 | .split('T')[0] 115 | .split('-') 116 | 117 | expect(changelogContent).toMatch(` 118 | ## [Unreleased] 119 | 120 | ## [0.1.0] - ${year}-${month}-${day} 121 | ### Added 122 | - My first feature 123 | 124 | 125 | [Unreleased]: https://github.com/my-org/my-repo/compare/v0.1.0...HEAD 126 | [0.1.0]: https://github.com/my-org/my-repo/compare/v0.0.0...v0.1.0`) 127 | }) 128 | }) 129 | 130 | describe('with wrongly formatted changelog', () => { 131 | beforeEach(() => { 132 | mockFs({ 133 | 'package.json': JSON.stringify({ name: 'my-app', version: '0.0.0' }), 134 | 'CHANGELOG.md': '', 135 | }) 136 | }) 137 | 138 | afterEach(() => { 139 | mockFs.restore() 140 | jest.restoreAllMocks() 141 | }) 142 | 143 | it('should log an error', async () => { 144 | const consoleErrorSpy = jest 145 | .spyOn(console, 'error') 146 | .mockImplementation(() => {}) 147 | 148 | jest 149 | .spyOn(steps, 'preReleasy') 150 | .mockImplementation(() => Promise.resolve()) 151 | 152 | await releasy(defaultOptions) 153 | 154 | expect(consoleErrorSpy).toHaveBeenCalledWith( 155 | expect.stringMatching('Cannot update your CHANGELOG file.') 156 | ) 157 | }) 158 | }) 159 | }) 160 | -------------------------------------------------------------------------------- /lib/releasy.js: -------------------------------------------------------------------------------- 1 | const { existsSync, readFileSync } = require('fs') 2 | const { execSync } = require('child_process') 3 | 4 | const chalk = require('chalk') 5 | const prompt = require('prompt') 6 | 7 | const Steps = require('./steps') 8 | 9 | module.exports = function (opts) { 10 | // Expose this to unit testing 11 | const steps = opts.steps || Steps 12 | 13 | const versionProvider = steps.pickVersionProvider(opts.filename) 14 | 15 | const config = steps.setup( 16 | versionProvider, 17 | opts.type, 18 | opts.stable ? 'stable' : opts.tagName 19 | ) 20 | 21 | const quiet = opts.quiet || process.env.NODE_ENV === 'test' 22 | 23 | if (!quiet) { 24 | console.log(`Current version: ${chalk.bold(config.currentVersion)}`) 25 | console.log(`New version: ${chalk.yellow.bold(config.newVersion)}`) 26 | } 27 | 28 | // Pachamama v2 requires that version tags start with a 'v' character. 29 | config.tagName = `v${config.newVersion}` 30 | config.currentVersionTagName = `v${config.currentVersion}` 31 | 32 | if (opts.displayName) { 33 | // TODO: Validade with @reliability if the pachamama accepts this new tag structure. 34 | config.tagName = `${versionProvider.readName()}@${config.newVersion}` 35 | config.currentVersionTagName = `${versionProvider.readName()}@${ 36 | config.currentVersion 37 | }` 38 | } 39 | 40 | config.commitMessage = `Release ${config.tagName}` 41 | config.tagMessage = `Release ${config.tagName}` 42 | config.npmTag = opts.npmTag 43 | config.npmFolder = opts.npmFolder 44 | config.dryRun = opts.dryRun 45 | config.quiet = quiet 46 | config.unreleasedRaw = 'Unreleased' 47 | config.unreleased = `## [${config.unreleasedRaw}]` 48 | config.changelogPath = 'CHANGELOG.md' 49 | config.githubAuth = getGithubToken() 50 | config.githubInfo = getGithubRepo() 51 | config.release = { 52 | name: config.tagName, 53 | tag_name: config.tagName, 54 | body: getReleaseNotes() || ' ', 55 | draft: false, 56 | prerelease: false, 57 | } 58 | 59 | /** 60 | * Get and format GITHUB_API_TOKEN. 61 | * @return Github Authorization 62 | */ 63 | function getGithubToken() { 64 | if (process.env.GITHUB_API_TOKEN) { 65 | return process.env.GITHUB_API_TOKEN 66 | } 67 | 68 | if (!config.quiet && opts.notes) { 69 | console.log( 70 | chalk.yellow.bold( 71 | `You must set GITHUB_API_TOKEN env if you want to post the Release Notes of your project. 72 | Access https://github.com/settings/tokens/new to create a Personal Token. 73 | Check the settings section in README for details.` 74 | ) 75 | ) 76 | } 77 | } 78 | 79 | /** 80 | * Get information in CHANGELOG.md about 81 | * new version that will be released. 82 | * @return release notes between versions 83 | */ 84 | function getReleaseNotes() { 85 | if (!opts.notes) { 86 | return 87 | } 88 | 89 | if (!existsSync(config.changelogPath)) { 90 | if (!config.quiet) 91 | console.log( 92 | chalk.red.bold( 93 | 'Create a CHANGELOG.md if you want a Release Notes in your Github Project' 94 | ) 95 | ) 96 | 97 | return 98 | } 99 | 100 | const data = readFileSync(config.changelogPath, (err) => { 101 | if (err) throw new Error(`Error reading file: ${err}`) 102 | }).toString() 103 | 104 | if (data.indexOf(config.unreleased) < 0) { 105 | if (!config.quiet) { 106 | console.log( 107 | chalk.yellow.bold( 108 | `I can't post your Release Notes. :( 109 | 110 | You must follow the CHANGELOG conventions defined in http://keepachangelog.com/en/1.0.0/` 111 | ) 112 | ) 113 | } 114 | 115 | return 116 | } 117 | 118 | const unreleased = 119 | data.indexOf(config.unreleased) + config.unreleased.length 120 | 121 | const oldVersion = data.indexOf(config.currentVersion) - 4 122 | 123 | return oldVersion < 0 124 | ? data.substring(unreleased) 125 | : data.substring(unreleased, oldVersion) 126 | } 127 | 128 | /** 129 | * Get information about org and repo name 130 | * @return array with git organization and repo 131 | */ 132 | function getGithubRepo() { 133 | return execSync('git remote get-url origin') 134 | .toString() 135 | .trim() 136 | .replace('.git', '') 137 | .split(':')[1] 138 | .split('/') 139 | .slice(-2) 140 | } 141 | 142 | // No prompt necessary, release and finish. 143 | if (!opts.cli || opts.silent) { 144 | return steps.release(config, opts) 145 | } 146 | 147 | // User wants a confirmation prompt 148 | prompt.start() 149 | const property = { 150 | name: 'confirm', 151 | type: 'string', 152 | description: chalk.green('Are you sure?'), 153 | default: 'yes', 154 | required: true, 155 | before: (value) => { 156 | return value === 'yes' || value === 'y' 157 | }, 158 | } 159 | 160 | return new Promise((resolve, reject) => { 161 | prompt.get(property, (err, result) => { 162 | if (err || !result.confirm) { 163 | console.log('\nCancelled by user') 164 | 165 | return 166 | } 167 | 168 | steps.release(config, opts).then(resolve, reject) 169 | }) 170 | }) 171 | } 172 | -------------------------------------------------------------------------------- /test/releasy.test.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | 3 | const Releasy = require('../lib/releasy.js') 4 | const steps = require('../lib/steps.js') 5 | const { createPackageJson, MANIFEST } = require('./utils') 6 | 7 | describe('releasy', () => { 8 | afterEach(() => { 9 | if (fs.existsSync('test/fixtures/manifest.json')) 10 | fs.unlinkSync('test/fixtures/manifest.json') 11 | jest.restoreAllMocks() 12 | }) 13 | 14 | it('should call all steps in dry run', () => { 15 | createPackageJson('test/fixtures/manifest.json', MANIFEST) 16 | 17 | const options = { 18 | dryRun: true, 19 | filename: 'test/fixtures/testpackage.json', 20 | type: 'patch', 21 | steps, 22 | quiet: true, 23 | } 24 | 25 | const setupSpy = jest.spyOn(steps, 'setup') 26 | const releaseSpy = jest.spyOn(steps, 'release') 27 | const preReleasySpy = jest.spyOn(steps, 'preReleasy') 28 | const postReleasySpy = jest.spyOn(steps, 'postReleasy') 29 | const scriptsSpy = jest.spyOn(steps, 'scripts') 30 | const spawnSpy = jest.spyOn(steps, 'spawn') 31 | 32 | const releasy = Releasy(options) 33 | 34 | return releasy.then(() => { 35 | expect(setupSpy).toHaveBeenCalled() 36 | expect(setupSpy).toHaveReturnedWith( 37 | expect.objectContaining({ newVersion: '1.0.1' }) 38 | ) 39 | expect(releaseSpy).toHaveBeenCalled() 40 | expect(preReleasySpy).toHaveBeenCalled() 41 | expect(postReleasySpy).toHaveBeenCalled() 42 | expect(scriptsSpy).toHaveBeenCalled() 43 | expect(spawnSpy).toHaveBeenNthCalledWith( 44 | 1, 45 | 'echo pre', 46 | 'Pre releasy', 47 | true, 48 | true 49 | ) 50 | expect(spawnSpy).toHaveBeenNthCalledWith( 51 | 2, 52 | 'echo post', 53 | 'Post releasy', 54 | true, 55 | true 56 | ) 57 | }) 58 | }) 59 | 60 | it('should call all steps in dry run using manifest', () => { 61 | createPackageJson('test/fixtures/manifest.json', MANIFEST) 62 | 63 | const options = { 64 | dryRun: true, 65 | filename: 'test/fixtures/testversionnull.json', 66 | type: 'patch', 67 | steps, 68 | quiet: true, 69 | } 70 | 71 | const setupSpy = jest.spyOn(steps, 'setup') 72 | const releaseSpy = jest.spyOn(steps, 'release') 73 | const preReleasySpy = jest.spyOn(steps, 'preReleasy') 74 | const postReleasySpy = jest.spyOn(steps, 'postReleasy') 75 | const scriptsSpy = jest.spyOn(steps, 'scripts') 76 | const spawnSpy = jest.spyOn(steps, 'spawn') 77 | 78 | const releasy = Releasy(options) 79 | 80 | return releasy.then(() => { 81 | expect(setupSpy).toHaveBeenCalled() 82 | expect(setupSpy).toHaveReturnedWith( 83 | expect.objectContaining({ newVersion: '0.3.1' }) 84 | ) 85 | expect(releaseSpy).toHaveBeenCalled() 86 | expect(preReleasySpy).toHaveBeenCalled() 87 | expect(postReleasySpy).toHaveBeenCalled() 88 | expect(scriptsSpy).toHaveBeenCalled() 89 | expect(spawnSpy).toHaveBeenNthCalledWith( 90 | 1, 91 | 'echo pre', 92 | 'Pre releasy', 93 | true, 94 | true 95 | ) 96 | expect(spawnSpy).toHaveBeenNthCalledWith( 97 | 2, 98 | 'echo post', 99 | 'Post releasy', 100 | true, 101 | true 102 | ) 103 | }) 104 | }) 105 | 106 | it("should default to manifest.json when file doesn't exist", () => { 107 | createPackageJson('test/fixtures/manifest.json', MANIFEST) 108 | 109 | const options = { 110 | dryRun: true, 111 | filename: 'test/fixtures/package.json', 112 | type: 'patch', 113 | steps, 114 | quiet: true, 115 | } 116 | 117 | const setupSpy = jest.spyOn(steps, 'setup') 118 | const releaseSpy = jest.spyOn(steps, 'release') 119 | const preReleasySpy = jest.spyOn(steps, 'preReleasy') 120 | const postReleasySpy = jest.spyOn(steps, 'postReleasy') 121 | const scriptsSpy = jest.spyOn(steps, 'scripts') 122 | const spawnSpy = jest.spyOn(steps, 'spawn') 123 | 124 | const releasy = Releasy(options) 125 | 126 | return releasy.then(() => { 127 | expect(setupSpy).toHaveBeenCalled() 128 | expect(setupSpy).toHaveReturnedWith( 129 | expect.objectContaining({ newVersion: '0.3.1' }) 130 | ) 131 | expect(releaseSpy).toHaveBeenCalled() 132 | expect(releaseSpy).toHaveBeenCalledWith( 133 | expect.objectContaining({ 134 | versionProvider: expect.objectContaining({ 135 | filePath: expect.arrayContaining(['test/fixtures/manifest.json']), 136 | }), 137 | }), 138 | expect.anything() 139 | ) 140 | expect(preReleasySpy).toHaveBeenCalled() 141 | expect(postReleasySpy).toHaveBeenCalled() 142 | expect(scriptsSpy).toHaveBeenCalled() 143 | expect(spawnSpy).toHaveBeenNthCalledWith( 144 | 1, 145 | 'echo pre', 146 | 'Pre releasy', 147 | true, 148 | true 149 | ) 150 | expect(spawnSpy).toHaveBeenNthCalledWith( 151 | 2, 152 | 'echo post', 153 | 'Post releasy', 154 | true, 155 | true 156 | ) 157 | }) 158 | }) 159 | 160 | it('should use the package name', () => { 161 | const options = { 162 | dryRun: true, 163 | filename: 'test/fixtures/testpackage.json', 164 | type: 'patch', 165 | steps, 166 | quiet: true, 167 | displayName: true, 168 | } 169 | 170 | const setupSpy = jest.spyOn(steps, 'setup') 171 | const releasy = Releasy(options) 172 | 173 | return releasy.then(() => { 174 | expect(setupSpy).toHaveBeenCalled() 175 | expect(setupSpy).toHaveReturnedWith( 176 | expect.objectContaining({ tagName: 'releasy@1.0.1' }) 177 | ) 178 | }) 179 | }) 180 | }) 181 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Releasy 2 | 3 | [![Gitter](https://badges.gitter.im/Join%20Chat.svg)](https://gitter.im/vtex/releasy?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) 4 | 5 | Releasy helps you release versions of your projects easily! It currently works with [NodeJS package.json files](#json-files) and [C# AssemblyInfo.cs files](#c-files). 6 | 7 | Releasy will automatically do the following: 8 | 9 | - Increment the version in the `manifest.json` or `package.json` file; 10 | - Commit the changed version file; 11 | - Create a Git tag with the version; 12 | - Push the tag and changes to the Git remote; 13 | - If exists, increment version and date in the `CHANGELOG.md`; 14 | - For this, you need to follow the format of CHANGELOG of [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) 15 | - See [CHANGELOG example area](#changelog-example) 16 | - Post the release notes from CHANGELOG on GitHub release. 17 | 18 | ## Settings 19 | 20 | A [GitHub Personal access token](https://help.github.com/articles/creating-an-access-token-for-command-line-use/) will be needed to create the release on GitHub and with all `repo` permissions. When you created, add the token to an environment variable named `GITHUB_API_TOKEN` in your `~/.bash_profile` (for bash users) or `~/.config/fish/config.fish` (for fish users) by adding the following line at the end of the file. 21 | 22 | ``` 23 | export GITHUB_API_TOKEN= 24 | ``` 25 | 26 | ## Usage 27 | 28 | If you want to see what happens, grab it (`npm i -g releasy`) and run anything with the **`--dry-run`** flag. This mode will only show you what would happen, without actually applying any changes. At any time, calling `releasy -h` or `releasy --help` will show you the list of options available. Try it. 29 | 30 | The **default behavior** increments the `patch` and creates a `beta` prerelease using the `package.json` file. 31 | 32 | ```sh 33 | $ releasy 34 | 35 | Old version: 1.0.0 36 | New version: 1.0.1-beta 37 | prompt: Are you sure?: (yes) 38 | Starting release... 39 | Version bumped to 1.0.1-beta 40 | File package.json added # git add package.json 41 | File package.json committed # git commit package.json -m "Release v1.0.1-beta" 42 | Tag created: v1.0.1-beta #git tag v1.0.1-beta -m "Release v1.0.1-beta" 43 | Pushed commit and tags # git push && git push --tags 44 | All steps finished successfully. 45 | ``` 46 | 47 | You can **increment other parts** of the version by providing a first argument: 48 | 49 | ```sh 50 | $ releasy patch # 1.2.3 => 1.2.4-beta 51 | $ releasy minor # 1.2.3 => 1.3.0-beta 52 | $ releasy major # 1.2.3 => 2.0.0-beta 53 | $ releasy prerelease # 1.2.3-beta.4 => 1.2.3-beta.5 54 | $ releasy pre # is an alias to 'prerelease' 55 | ``` 56 | 57 | When you are ready to **promote a beta version to stable**, use the `promote` argument: 58 | 59 | ```sh 60 | $ releasy promote # 1.2.3-beta.4 => 1.2.3 61 | ``` 62 | 63 | Or, if you want to **increment directly as stable** version, use the `--stable` option: 64 | 65 | ```sh 66 | $ releasy --stable # 1.2.3 => 1.2.4 67 | ``` 68 | 69 | To apply a **custom prerelease identifier**: 70 | 71 | ```sh 72 | $ releasy --tag-name alpha # 1.2.3 => 1.2.4-alpha 73 | ``` 74 | 75 | If you want to **post the release notes on GitHub**, use the `--notes` option: 76 | 77 | ```sh 78 | $ releasy --stable --notes # Release Notes submitted 79 | ``` 80 | 81 | If you want to prevent releasy from automatically **committing, tagging or pushing**, use the `--no-commit`/`--no-tag`/`--no-push` options: 82 | 83 | ```sh 84 | $ releasy --stable --no-tag --no-push 85 | ``` 86 | 87 | ## Options file 88 | 89 | You **may** create a file called `_releasy.yaml` to any values set in this file will be used as default. If you prefer, `.yml` and `.json` extensions will also work. Below is a sample `_releasy.yaml` file. 90 | 91 | ```yaml 92 | # https://github.com/vtex/releasy 93 | type: prerelease # prerelease as default increment 94 | filename: otherpackage.json # different version file as default 95 | 96 | # you may also use any other options available on the command line 97 | stable: true # release stable version 98 | tag: alpha # use alpha as prerelease name 99 | dry-run: true # always use dry-run mode 100 | 101 | no-tag: true # don't tag the release commit 102 | no-push: true # don't push to the remote repository 103 | no-commit: true # don't create the release commit 104 | display-name: true # add the project name to the tag and release commit 105 | # etc 106 | ``` 107 | 108 | ## Different version files 109 | 110 | Releasy currently supports both NodeJS' package.json and .NET C#'s AssemblyInfo.cs. The default file used is `package.json`, but you may specify a different value through the options file or in the command line. 111 | 112 | ### JSON files 113 | 114 | If the specified file has a `.json` extension, it will be treated as Node's `package.json`. This means that the version will be read from and written to your package's `version` field. 115 | 116 | ### C# files 117 | 118 | If the specified file has a `.cs` extension, it will be treated as an `AssemblyInfo.cs` file. As such, the version will be read from and written to assembly version attributes, which are: [`AssemblyVersion`](), [`AssemblyFileVersion`]() and [`AssemblyInformationalVersion`](). 119 | 120 | To conform to the .NET Framework's specification, only the `AssemblyInformationalVersion` attribute will retain any prerelease version information, while the other two will be stripped of it, keeping just the version numbers. 121 | 122 | ### CHANGELOG example 123 | 124 | The format of your changelog is according to [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) that requires an `## [Unreleased]` section for the next release, and the types of changes below this section. 125 | 126 | An example of a first CHANGELOG.md to create before using a `releasy` command: 127 | 128 | ```markdown 129 | # Changelog 130 | 131 | All notable changes to this project will be documented in this file. 132 | 133 | The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) 134 | and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). 135 | 136 | ## [Unreleased] 137 | 138 | ### Added 139 | 140 | - My new feature 141 | 142 | ### Fixed 143 | 144 | - An bug 145 | ``` 146 | -------------------------------------------------------------------------------- /test/steps.test.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | 3 | const semver = require('semver') 4 | 5 | const steps = require('../lib/steps.js') 6 | const writeToFile = require('../lib/includes/writeToFile') 7 | 8 | describe('Steps', () => { 9 | beforeEach(() => process.chdir('test')) 10 | 11 | afterEach(() => { 12 | if (fs.existsSync('package.json')) fs.unlinkSync('package.json') 13 | if (fs.existsSync('src/ProductAssemblyInfo.json')) 14 | fs.unlinkSync('package.json') 15 | process.chdir('..') 16 | }) 17 | 18 | describe('picking version provider', () => { 19 | it('should pick first matching provider', () => { 20 | // arrange 21 | 22 | writeToFile('myversion.ext', '') 23 | const p1 = function Provider1() { 24 | this.name = 'p1' 25 | } 26 | 27 | p1.supports = () => false 28 | 29 | const p2 = function Provider2() { 30 | this.name = 'p2' 31 | } 32 | 33 | p2.supports = () => true 34 | 35 | const p3 = function Provider3() { 36 | this.name = 'p3' 37 | } 38 | 39 | p3.supports = () => false 40 | 41 | const providers = [p1, p2, p3] 42 | 43 | // act 44 | const provider = steps.pickVersionProvider('myversion.ext', providers) 45 | 46 | // assert 47 | expect(provider.name).toBe('p2') 48 | fs.unlinkSync('myversion.ext') 49 | }) 50 | 51 | it('should throw error if a provider cannot be found', () => { 52 | // arrange 53 | writeToFile('myversion.bla', '') 54 | const providers = [{ supports: () => false }, { supports: () => false }] 55 | 56 | // act & assert 57 | expect(() => 58 | steps.pickVersionProvider('myversion.bla', providers) 59 | ).toThrow(/^Unable to find a provider that supports/) 60 | 61 | fs.unlinkSync('myversion.bla') 62 | }) 63 | 64 | it('should throw error if file does not exist', () => { 65 | const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation() 66 | 67 | expect( 68 | // Force `manifest.json` to not be found. 69 | () => steps.pickVersionProvider('somedir/somejsonfile.json') 70 | ).toThrow(/^Version file not found:/) 71 | expect(consoleErrorSpy).toHaveBeenCalledWith( 72 | "ENOENT: no such file or directory, open 'somedir/somejsonfile.json'" 73 | ) 74 | }) 75 | }) 76 | 77 | describe('setup', () => { 78 | it('should not promote a stable version', () => { 79 | // arrange 80 | const provider = { 81 | readVersion() { 82 | return semver('1.2.3') 83 | }, 84 | } 85 | 86 | // act & assert 87 | expect(() => steps.setup(provider, 'promote', '')).toThrow() 88 | }) 89 | 90 | it('should set config', () => { 91 | // arrange 92 | const provider = { 93 | readVersion() { 94 | return semver('1.2.3') 95 | }, 96 | } 97 | 98 | // act 99 | const config = steps.setup(provider, 'patch', '') 100 | 101 | // assert 102 | expect(config.newVersion).toBe('1.2.4') 103 | expect(config.currentVersion).toBe('1.2.3') 104 | expect(config.versionProvider).toBe(provider) 105 | }) 106 | 107 | it('should bump patch', () => { 108 | // arrange 109 | const provider = { 110 | readVersion() { 111 | return semver('1.2.3') 112 | }, 113 | } 114 | 115 | // act 116 | const config = steps.setup(provider, 'patch', '') 117 | 118 | // assert 119 | expect(config.newVersion).toBe('1.2.4') 120 | }) 121 | 122 | it('should bump minor', () => { 123 | // arrange 124 | const provider = { 125 | readVersion() { 126 | return semver('1.2.3') 127 | }, 128 | } 129 | 130 | // act 131 | const config = steps.setup(provider, 'minor', '') 132 | 133 | // assert 134 | expect(config.newVersion).toBe('1.3.0') 135 | }) 136 | 137 | it('should bump major', () => { 138 | // arrange 139 | const provider = { 140 | readVersion() { 141 | return semver('1.2.3') 142 | }, 143 | } 144 | 145 | // act 146 | const config = steps.setup(provider, 'major', '') 147 | 148 | // assert 149 | expect(config.newVersion).toBe('2.0.0') 150 | }) 151 | 152 | it('should bump prerelease', () => { 153 | // arrange 154 | const provider = { 155 | readVersion() { 156 | return semver('1.2.3-beta.4') 157 | }, 158 | } 159 | 160 | // act 161 | const config = steps.setup(provider, 'prerelease', '') 162 | 163 | // assert 164 | expect(config.newVersion).toBe('1.2.3-beta.5') 165 | }) 166 | 167 | it('should create prerelease', () => { 168 | // arrange 169 | const provider = { 170 | readVersion() { 171 | return semver('1.2.3') 172 | }, 173 | } 174 | 175 | // act 176 | const config = steps.setup(provider, 'patch', 'beta') 177 | 178 | // assert 179 | expect(config.newVersion).toBe('1.2.4-beta') 180 | }) 181 | 182 | it('should promote prerelease', () => { 183 | // arrange 184 | const provider = { 185 | readVersion() { 186 | return semver('1.2.3-beta.4') 187 | }, 188 | } 189 | 190 | // act 191 | const config = steps.setup(provider, 'promote', '') 192 | 193 | // assert 194 | expect(config.newVersion).toBe('1.2.3') 195 | }) 196 | }) 197 | 198 | describe('get options file', () => { 199 | it('should use _releasy.yaml file', () => { 200 | // arrange 201 | writeToFile( 202 | '_releasy.yaml', 203 | `\ 204 | default: major\ 205 | ` 206 | ) 207 | 208 | // act 209 | const options = steps.getOptionsFile() 210 | 211 | // assert 212 | fs.unlinkSync('_releasy.yaml') 213 | expect(options.default).toBe('major') 214 | }) 215 | 216 | it('should use _releasy.yml file', () => { 217 | // arrange 218 | writeToFile( 219 | '_releasy.yml', 220 | `\ 221 | default: major\ 222 | ` 223 | ) 224 | 225 | // act 226 | const options = steps.getOptionsFile() 227 | 228 | // assert 229 | fs.unlinkSync('_releasy.yml') 230 | expect(options.default).toBe('major') 231 | }) 232 | 233 | it('should use _releasy.json file', () => { 234 | // arrange 235 | writeToFile( 236 | '_releasy.json', 237 | `\ 238 | { 239 | "default": "major" 240 | }\ 241 | ` 242 | ) 243 | 244 | // act 245 | const options = steps.getOptionsFile() 246 | 247 | // assert 248 | fs.unlinkSync('_releasy.json') 249 | expect(options.default).toBe('major') 250 | }) 251 | 252 | it('should return empty object if no file is found', () => { 253 | // act 254 | const options = steps.getOptionsFile() 255 | 256 | // assert 257 | expect(options).toEqual({}) 258 | }) 259 | }) 260 | }) 261 | -------------------------------------------------------------------------------- /test/csharp-provider.test.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | 3 | const semver = require('semver') 4 | 5 | const CsharpVersionProvider = require('../lib/providers/csharp.js') 6 | const writeToFile = require('../lib/includes/writeToFile') 7 | 8 | describe('CsharpVersionProvider', () => { 9 | afterEach(() => { 10 | if (fs.existsSync('test/fixtures/AssemblyInfo.cs')) 11 | fs.unlinkSync('test/fixtures/AssemblyInfo.cs') 12 | }) 13 | 14 | describe('reading C# version', () => { 15 | it('should return SemVer object from informational version', () => { 16 | // arrange 17 | writeToFile( 18 | 'test/fixtures/AssemblyInfo.cs', 19 | `\ 20 | [assembly: AssemblyVersion("1.2.3")] 21 | [assembly: AssemblyFileVersion("1.2.3")] 22 | [assembly: AssemblyInformationalVersion("1.2.3-beta.4")]\ 23 | ` 24 | ) 25 | const provider = new CsharpVersionProvider( 26 | 'test/fixtures/AssemblyInfo.cs' 27 | ) 28 | 29 | // act 30 | const version = provider.readVersion() 31 | 32 | // assert 33 | expect(version.format()).toBe('1.2.3-beta.4') 34 | }) 35 | 36 | it('should fall back to file version', () => { 37 | // arrange 38 | writeToFile( 39 | 'test/fixtures/AssemblyInfo.cs', 40 | `\ 41 | [assembly: AssemblyVersion("1.2.3")] 42 | [assembly: AssemblyFileVersion("1.2.3")]\ 43 | ` 44 | ) 45 | const provider = new CsharpVersionProvider( 46 | 'test/fixtures/AssemblyInfo.cs' 47 | ) 48 | 49 | // act 50 | const version = provider.readVersion() 51 | 52 | // assert 53 | expect(version.format()).toBe('1.2.3') 54 | }) 55 | 56 | it('should fall back to assembly version', () => { 57 | // arrange 58 | writeToFile( 59 | 'test/fixtures/AssemblyInfo.cs', 60 | `\ 61 | [assembly: AssemblyVersion("1.2.3")]\ 62 | ` 63 | ) 64 | const provider = new CsharpVersionProvider( 65 | 'test/fixtures/AssemblyInfo.cs' 66 | ) 67 | 68 | // act 69 | const version = provider.readVersion() 70 | 71 | // assert 72 | expect(version.format()).toBe('1.2.3') 73 | }) 74 | 75 | it('should throw an error if a version cannot be found', () => { 76 | // arrange 77 | writeToFile( 78 | 'test/fixtures/AssemblyInfo.cs', 79 | `\ 80 | // no version in here!\ 81 | ` 82 | ) 83 | const provider = new CsharpVersionProvider( 84 | 'test/fixtures/AssemblyInfo.cs' 85 | ) 86 | 87 | // act & assert 88 | expect(() => provider.readVersion()).toThrow( 89 | 'Could not find version information in file test/fixtures/AssemblyInfo.cs' 90 | ) 91 | }) 92 | }) 93 | 94 | describe('writing C# version', () => { 95 | it('should accept SemVer object', () => { 96 | // arrange 97 | writeToFile( 98 | 'test/fixtures/AssemblyInfo.cs', 99 | `\ 100 | [assembly: AssemblyVersion("1.2.3")] 101 | [assembly: AssemblyFileVersion("1.2.3")] 102 | [assembly: AssemblyInformationalVersion("1.2.3-beta.4")]\ 103 | ` 104 | ) 105 | const provider = new CsharpVersionProvider( 106 | 'test/fixtures/AssemblyInfo.cs' 107 | ) 108 | 109 | // act 110 | provider.writeVersion(semver('2.3.4-alpha.5')) 111 | 112 | // assert 113 | expect(fs.readFileSync('test/fixtures/AssemblyInfo.cs').toString()).toBe( 114 | `\ 115 | [assembly: AssemblyVersion("2.3.4")] 116 | [assembly: AssemblyFileVersion("2.3.4")] 117 | [assembly: AssemblyInformationalVersion("2.3.4-alpha.5")]\ 118 | ` 119 | ) 120 | }) 121 | 122 | it('should accept string version', () => { 123 | // arrange 124 | writeToFile( 125 | 'test/fixtures/AssemblyInfo.cs', 126 | `\ 127 | [assembly: AssemblyVersion("1.2.3")] 128 | [assembly: AssemblyFileVersion("1.2.3")] 129 | [assembly: AssemblyInformationalVersion("1.2.3-beta.4")]\ 130 | ` 131 | ) 132 | const provider = new CsharpVersionProvider( 133 | 'test/fixtures/AssemblyInfo.cs' 134 | ) 135 | 136 | // act 137 | provider.writeVersion('2.3.4-alpha.5') 138 | 139 | // assert 140 | expect(fs.readFileSync('test/fixtures/AssemblyInfo.cs').toString()).toBe( 141 | `\ 142 | [assembly: AssemblyVersion("2.3.4")] 143 | [assembly: AssemblyFileVersion("2.3.4")] 144 | [assembly: AssemblyInformationalVersion("2.3.4-alpha.5")]\ 145 | ` 146 | ) 147 | }) 148 | 149 | it('should append missing version attributes', () => { 150 | // arrange 151 | writeToFile( 152 | 'test/fixtures/AssemblyInfo.cs', 153 | `\ 154 | [assembly: AssemblyVersion("1.2.3")] 155 | // nothing else\ 156 | ` 157 | ) 158 | const provider = new CsharpVersionProvider( 159 | 'test/fixtures/AssemblyInfo.cs' 160 | ) 161 | 162 | // act 163 | provider.writeVersion('2.3.4-alpha.5') 164 | 165 | // assert 166 | expect(fs.readFileSync('test/fixtures/AssemblyInfo.cs').toString()).toBe( 167 | `\ 168 | [assembly: AssemblyVersion("2.3.4")] 169 | // nothing else 170 | [assembly: AssemblyFileVersion("2.3.4")] 171 | [assembly: AssemblyInformationalVersion("2.3.4-alpha.5")] 172 | \ 173 | ` 174 | ) 175 | }) 176 | 177 | it('should not mess line endings', () => { 178 | writeToFile( 179 | 'test/fixtures/AssemblyInfo.cs', 180 | '// [assembly: AssemblyVersion("x.x.x")]\r\n[assembly: AssemblyVersion("2.3.5")]\r\n[assembly: AssemblyFileVersion("2.3.5")]\r\n[assembly: AssemblyInformationalVersion("2.3.5-beta.3")]\r\n' 181 | ) 182 | 183 | const provider = new CsharpVersionProvider( 184 | 'test/fixtures/AssemblyInfo.cs' 185 | ) 186 | 187 | // act 188 | provider.writeVersion('2.3.4-alpha.5') 189 | 190 | // assert 191 | expect(fs.readFileSync('test/fixtures/AssemblyInfo.cs').toString()).toBe( 192 | '// [assembly: AssemblyVersion("2.3.4")]\r\n[assembly: AssemblyVersion("2.3.4")]\r\n[assembly: AssemblyFileVersion("2.3.4")]\r\n[assembly: AssemblyInformationalVersion("2.3.4-alpha.5")]\r\n' 193 | ) 194 | }) 195 | 196 | it('should append missing attributes without breaking extra line', () => { 197 | // arrange 198 | writeToFile( 199 | 'test/fixtures/AssemblyInfo.cs', 200 | `\ 201 | // [assembly: AssemblyVersion("2.3.5")] 202 | [assembly: AssemblyVersion("2.3.5")] 203 | [assembly: AssemblyInformationalVersion("2.3.5-beta.3")] 204 | \ 205 | ` 206 | ) 207 | const provider = new CsharpVersionProvider( 208 | 'test/fixtures/AssemblyInfo.cs' 209 | ) 210 | 211 | // act 212 | provider.writeVersion('2.3.4-alpha.5') 213 | 214 | // assert 215 | expect(fs.readFileSync('test/fixtures/AssemblyInfo.cs').toString()).toBe( 216 | `\ 217 | // [assembly: AssemblyVersion("2.3.4")] 218 | [assembly: AssemblyVersion("2.3.4")] 219 | [assembly: AssemblyInformationalVersion("2.3.4-alpha.5")] 220 | [assembly: AssemblyFileVersion("2.3.4")] 221 | \ 222 | ` 223 | ) 224 | }) 225 | }) 226 | 227 | describe('file support', () => { 228 | it('should support .cs extensions', () => { 229 | // act 230 | const supports = CsharpVersionProvider.supports('somefile.cs') 231 | 232 | // assert 233 | return expect(supports).toBe(true) 234 | }) 235 | 236 | it('should not support any other extension', () => { 237 | // act 238 | const supports = CsharpVersionProvider.supports('arbitrary.extension') 239 | 240 | // assert 241 | return expect(supports).toBe(false) 242 | }) 243 | }) 244 | }) 245 | -------------------------------------------------------------------------------- /lib/steps.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const { exec, spawn } = require('child_process') 3 | const util = require('util') 4 | 5 | const chalk = require('chalk') 6 | const Github = require('github-api') 7 | const yaml = require('js-yaml') 8 | const prompt = require('prompt') 9 | 10 | const providers = require('./providers') 11 | const { addChangelogVersionLinks } = require('./changelog') 12 | 13 | const execAsync = util.promisify(exec) 14 | 15 | const steps = { 16 | getOptionsFile: () => { 17 | const possibleFiles = ['_releasy.yaml', '_releasy.yml', '_releasy.json'] 18 | 19 | for (let i = 0; i < possibleFiles.length; i++) { 20 | const name = possibleFiles[i] 21 | 22 | if (fs.existsSync(name)) { 23 | return yaml.safeLoad(fs.readFileSync(name).toString()) 24 | } 25 | } 26 | 27 | return {} 28 | }, 29 | pickVersionProvider: (fileName, overrideProviders) => { 30 | const currentProviders = overrideProviders || providers 31 | 32 | for (const i in currentProviders) { 33 | const Provider = currentProviders[i] 34 | 35 | if (Provider.supports(fileName)) { 36 | return new Provider(fileName) 37 | } 38 | } 39 | 40 | throw new Error( 41 | util.format( 42 | "Unable to find a provider that supports '%s' as a version file", 43 | fileName 44 | ) 45 | ) 46 | }, 47 | setup: (versionProvider, type, prerelease) => { 48 | let version = versionProvider.readVersion() 49 | 50 | // Support for "promote" predicate, which bumps prerelease to stable, without changing version number. 51 | if (type === 'promote') { 52 | // Promote only makes sense when there is a prerelease 53 | if (version.prerelease.length === 0) { 54 | throw new Error( 55 | `The version you are trying to promote to stable (${version.format()}) is already stable.\n` 56 | ) 57 | } else { 58 | version.prerelease = [] 59 | prerelease = 'stable' 60 | } 61 | } 62 | // For other types, simply increment 63 | else { 64 | const incrementType = 65 | type === 'prerelease' || 66 | prerelease === 'stable' || 67 | version.prerelease.length === 0 68 | ? type || 'patch' 69 | : `pre${type}` || 'prepatch' 70 | 71 | version = version.inc(incrementType) 72 | } 73 | 74 | if (prerelease && prerelease !== 'stable' && type !== 'prerelease') { 75 | version.prerelease = [prerelease] 76 | } 77 | 78 | return { 79 | versionProvider, 80 | newVersion: version.format(), 81 | currentVersion: versionProvider.readVersion().format(), 82 | } 83 | }, 84 | scripts: async (msg, config, key) => { 85 | const pkg = /package\.json$/.test(config.versionProvider.filePath) 86 | const manifest = /manifest\.json$/.test(config.versionProvider.filePath) 87 | 88 | if (!(pkg || manifest)) return 89 | 90 | let cmd 91 | 92 | try { 93 | cmd = config.versionProvider.getScript(key) 94 | } catch (err) { 95 | return 96 | } 97 | 98 | return cmd 99 | ? steps.spawn(cmd, msg, config.dryRun, config.quiet) 100 | : Promise.resolve() 101 | }, 102 | preReleasy: async (config) => { 103 | const msg = 'Pre releasy' 104 | 105 | const preScriptStatus = await steps.status(config) 106 | 107 | if (!config.dryRun && preScriptStatus.indexOf('nothing to commit') === -1) { 108 | throw new Error('\nPlease commit your changes before proceeding.') 109 | } 110 | 111 | await steps.scripts(msg, config, 'prereleasy') 112 | const postScriptStatus = await steps.status(config) 113 | 114 | if (postScriptStatus.indexOf('nothing to commit') > -1) { 115 | return 116 | } 117 | 118 | const cmd = config.versionProvider.getScript('prereleasy') 119 | const preCfg = { 120 | commitMessage: `Pre releasy commit\n\n ${cmd}`, 121 | dryRun: config.dryRun, 122 | versionProvider: { 123 | filePath: '.', 124 | }, 125 | quiet: true, 126 | } 127 | 128 | await steps.commit(preCfg) 129 | }, 130 | run: async (cmd, successMessage, dryRun, quiet) => { 131 | const stdout = await (dryRun 132 | ? Promise.resolve('') 133 | : execAsync(cmd).then((result) => result.stdout)) 134 | 135 | if (successMessage && !quiet) { 136 | console.log(chalk`${successMessage} {blue > ${cmd}}`) 137 | } 138 | 139 | return stdout 140 | }, 141 | spawn: (cmd, successMessage, dryRun, quiet) => { 142 | return new Promise((resolve, reject) => { 143 | if (dryRun) { 144 | return resolve() 145 | } 146 | 147 | const childEnv = Object.create(process.env) 148 | const childIO = quiet ? null : 'inherit' 149 | 150 | let argsW32 = false 151 | let command 152 | let env 153 | let args 154 | 155 | if (/^(?:[A-Z]*_*)*[A-Z]*=/.test(cmd) && !dryRun) { 156 | env = cmd.split('=') 157 | childEnv[env[0]] = env[1].split(' ')[0] 158 | } 159 | 160 | if (process.platform === 'win32') { 161 | const argsCmd = env ? env[1].substr(env[1].indexOf(' ') + 1) : cmd 162 | 163 | args = ['/s', '/c', argsCmd] 164 | command = 'cmd.exe' 165 | argsW32 = true 166 | } else { 167 | args = env ? env[1].split(' ').slice(1) : cmd.split(' ') 168 | command = args.shift() 169 | } 170 | 171 | const childProcess = spawn(command, args, { 172 | env: childEnv, 173 | stdio: childIO, 174 | windowsVerbatimArguments: argsW32, 175 | }) 176 | 177 | childProcess.on('close', (code) => { 178 | if (code === 0) { 179 | return resolve() 180 | } 181 | 182 | reject(`\nCommand exited with error code: ${code}`) 183 | }) 184 | }).then(() => { 185 | if (!quiet) { 186 | console.log(chalk`${successMessage} {blue > ${cmd}}`) 187 | } 188 | }) 189 | }, 190 | status: (config) => { 191 | return steps.run('git status', '', false, config.quiet) 192 | }, 193 | bump: (config) => { 194 | // Update version on CHANGELOG.md 195 | if (fs.existsSync(config.changelogPath)) { 196 | const changelogContent = fs 197 | .readFileSync(config.changelogPath, (err) => { 198 | if (err) throw new Error(`Error reading file: ${err}`) 199 | }) 200 | .toString() 201 | 202 | if (changelogContent.indexOf(config.unreleased) < 0) { 203 | console.error( 204 | chalk.red.bold( 205 | `Cannot update your CHANGELOG file. 206 | 207 | You must follow the CHANGELOG conventions defined in http://keepachangelog.com/en/1.0.0/` 208 | ) 209 | ) 210 | } else { 211 | let updatedChangelog = changelogContent 212 | 213 | const [year, month, day] = new Date() 214 | .toISOString() 215 | .split('T')[0] 216 | .split('-') 217 | 218 | const changelogVersion = `\n\n## [${config.newVersion}] - ${year}-${month}-${day}` 219 | 220 | const startIndex = 221 | updatedChangelog.indexOf(config.unreleased) + config.unreleased.length 222 | 223 | updatedChangelog = `${updatedChangelog.slice( 224 | 0, 225 | startIndex 226 | )}${changelogVersion}${updatedChangelog.substring(startIndex)}` 227 | 228 | updatedChangelog = addChangelogVersionLinks(config, updatedChangelog) 229 | 230 | if (!config.dryRun) { 231 | fs.writeFileSync(config.changelogPath, updatedChangelog) 232 | } 233 | } 234 | } 235 | 236 | const promise = config.dryRun 237 | ? Promise.resolve() 238 | : Promise.resolve(config.versionProvider.writeVersion(config.newVersion)) 239 | 240 | return promise.then((result) => { 241 | if (!config.quiet) 242 | console.log(`Version bumped to ${chalk.green.bold(config.newVersion)}`) 243 | 244 | return result 245 | }) 246 | }, 247 | add: (config) => { 248 | let gitAddCommand = `git add ${config.versionProvider.filePath.join(' ')}` 249 | let successMessage = `File(s) ${config.versionProvider.filePath} added` 250 | 251 | if (fs.existsSync(config.changelogPath)) { 252 | gitAddCommand += ` ${config.changelogPath}` 253 | successMessage = `Files ${config.versionProvider.filePath} ${config.changelogPath} added` 254 | } 255 | 256 | return steps.run(gitAddCommand, successMessage, config.dryRun, config.quiet) 257 | }, 258 | commit: (config) => { 259 | let successMessage = `File(s) ${config.versionProvider.filePath} commited` 260 | 261 | if (fs.existsSync(config.changelogPath)) { 262 | successMessage = `Files ${config.versionProvider.filePath} ${config.changelogPath} commited` 263 | } 264 | 265 | return steps.run( 266 | `git commit -m "${config.commitMessage}"`, 267 | successMessage, 268 | config.dryRun, 269 | config.quiet 270 | ) 271 | }, 272 | tag: (config) => { 273 | return steps.run( 274 | `git tag ${config.tagName} -m "${config.tagMessage}"`, 275 | `Tag created: ${config.tagName}`, 276 | config.dryRun, 277 | config.quiet 278 | ) 279 | }, 280 | push: (config) => { 281 | return steps.run( 282 | 'git push --follow-tags', 283 | 'Pushed commit and tags', 284 | config.dryRun, 285 | config.quiet 286 | ) 287 | }, 288 | releaseNotes: (config) => { 289 | // Post Release Notes at the Github 290 | const { changelogPath, githubAuth, githubInfo, release, dryRun } = config 291 | 292 | if (!fs.existsSync(changelogPath) || !githubAuth || dryRun) { 293 | console.log( 294 | chalk.yellow("You don't have a CHANGELOG to post Release Notes") 295 | ) 296 | 297 | return Promise.resolve() 298 | } 299 | 300 | const client = new Github({ token: githubAuth }) 301 | 302 | return client 303 | .getRepo(githubInfo[0], githubInfo[1]) 304 | .createRelease(release) 305 | .then(() => { 306 | if (!config.quiet) console.log(chalk.green('Release Notes submitted')) 307 | }) 308 | .catch((err) => { 309 | throw new Error(`Error on request ${err}`) 310 | }) 311 | }, 312 | postReleasy: (config) => { 313 | const msg = 'Post releasy' 314 | 315 | return steps.scripts(msg, config, 'postreleasy') 316 | }, 317 | publish: async (config, { otp = '', otpMessage = '', otpRetries = 0 }) => { 318 | let cmd = 'npm publish' 319 | let msg = `Published ${config.newVersion} to npm` 320 | 321 | if (config.npmTag) { 322 | cmd += ` --tag ${config.npmTag}` 323 | msg += `with a tag of "${config.npmTag}"` 324 | } 325 | 326 | if (otp) { 327 | cmd += ` --otp ${otp}` 328 | } 329 | 330 | if (config.npmFolder) { 331 | cmd += ` ${config.npmFolder}` 332 | } 333 | 334 | try { 335 | return await steps.run(cmd, msg, config.dryRun, config.quiet) 336 | } catch (error) { 337 | const isOTPError = error.message.includes('code EOTP') 338 | 339 | if (isOTPError && otpRetries > 0) { 340 | if (otpMessage) { 341 | console.log(otpMessage) 342 | } 343 | 344 | return new Promise((resolve, reject) => { 345 | // User has enabled two-factor authentication to 346 | // publish NPM packages, so prompt user for it and retry publishing 347 | prompt.start() 348 | prompt.get( 349 | { 350 | name: 'otp', 351 | type: 'string', 352 | description: 'npm one-time password', 353 | default: '', 354 | required: true, 355 | }, 356 | (err, result) => { 357 | if (err || !result.otp) { 358 | reject('Cancelled by user') 359 | 360 | return 361 | } 362 | 363 | steps 364 | .publish(config, { 365 | otp: result.otp, 366 | otpMessage: 'Incorrect or expired OTP', 367 | otpRetries: otpRetries - 1, 368 | }) 369 | .then(resolve, reject) 370 | } 371 | ) 372 | }) 373 | } 374 | 375 | if (isOTPError) { 376 | throw new Error( 377 | 'OTP code is incorrect or expired and you have runned out of attemps' 378 | ) 379 | } 380 | 381 | throw error 382 | } 383 | }, 384 | release: async (config, options) => { 385 | try { 386 | if (!config.quiet) console.log('Starting release...') 387 | 388 | await steps.preReleasy(config) 389 | await steps.bump(config) 390 | 391 | if (options.commit) { 392 | await steps.add(config) 393 | await steps.commit(config) 394 | } 395 | 396 | if (options.tag) { 397 | await steps.tag(config) 398 | } 399 | 400 | if (options.push) { 401 | await steps.push(config) 402 | } 403 | 404 | if (options.notes) { 405 | await steps.releaseNotes(config) 406 | } 407 | 408 | if (options.npm) { 409 | await steps.publish(config, { 410 | otp: options.otp, 411 | // In case the otp code was passed via options, we won't 412 | // retry as this is likely a script which won't know how 413 | // to respond to our prompt in case the publish fails 414 | otpRetries: options.otp ? 0 : 3, 415 | }) 416 | } 417 | 418 | await steps.postReleasy(config) 419 | 420 | if (!config.quiet) { 421 | console.log(chalk.green('All steps finished successfully.')) 422 | } 423 | } catch (reason) { 424 | if (!config.quiet) { 425 | console.error( 426 | chalk.red.bold('[ERROR] Failed to release.\n'), 427 | reason instanceof Error ? reason.message : reason 428 | ) 429 | } 430 | } 431 | }, 432 | } 433 | 434 | module.exports = steps 435 | --------------------------------------------------------------------------------