diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..fa6f8ca3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,131 @@ + +# Created by https://www.gitignore.io/api/macos,windows,linux,node + +### Linux ### +*~ + +# temporary files which can be created if a process still has a handle open of a deleted file +.fuse_hidden* + +# KDE directory preferences +.directory + +# Linux trash folder which might appear on any partition or disk +.Trash-* + +# .nfs files are created when an open file is removed but is still being accessed +.nfs* + +### macOS ### +*.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +### Node ### +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (http://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Typescript v1 declaration files +typings/ + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variables file +.env + + +### Windows ### +# Windows thumbnail cache files +Thumbs.db +ehthumbs.db +ehthumbs_vista.db + +# Folder config file +Desktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msm +*.msp + +# Windows shortcuts +*.lnk + +# End of https://www.gitignore.io/api/macos,windows,linux,node + +package-lock.json +yarn.lock diff --git a/.npmrc b/.npmrc new file mode 100644 index 00000000..43c97e71 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +package-lock=false diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 00000000..2cf6308c --- /dev/null +++ b/.travis.yml @@ -0,0 +1,26 @@ +language: node_js +services: + - docker +notifications: + email: false +node_js: + - 9 + - 8 + +# Trigger a push build on master and greenkeeper branches + PRs build on every branches +# Avoid double build on PRs (See https://github.com/travis-ci/travis-ci/issues/1147) +branches: + only: + - master + - /^greenkeeper.*$/ + +# Retry install on fail to avoid failing a build on network/disk/external errors +install: + - travis_retry npm install + +script: + - npm run test + +after_success: + - npm run codecov + - npm run semantic-release diff --git a/.yarnrc b/.yarnrc new file mode 100644 index 00000000..acaaffdb --- /dev/null +++ b/.yarnrc @@ -0,0 +1 @@ +--install.no-lockfile true diff --git a/LICENSE b/LICENSE index 8864d4a3..8e443427 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2017 +Copyright (c) 2017 Contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 2a5f6414..aa7b3aac 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,95 @@ -# github -Set of semantic-release plugins for publishing a Github release +# @semantic-release/github + +Set of [semantic-release](https://github.com/semantic-release/semantic-release) plugins for publishing a [Github release](https://help.github.com/articles/about-releases). + +[![Travis](https://img.shields.io/travis/semantic-release/github.svg)](https://travis-ci.org/semantic-release/github) +[![Codecov](https://img.shields.io/codecov/c/github/semantic-release/github.svg)](https://codecov.io/gh/semantic-release/github) +[![Greenkeeper badge](https://badges.greenkeeper.io/semantic-release/github.svg)](https://greenkeeper.io/) + +## verifyConditions + +Verify the presence and the validity of the `githubToken` (set via option or environment variable). + +### Options + +| Option | Description | Default | +| --------------------- | --------------------------------------------------------- | ------------------------------------------------------ | +| `githubToken` | **Required.** The token used to authenticate with GitHub. | `process.env.GH_TOKEN` or `process.env.GITHUB_TOKEN` | +| `githubUrl` | The GitHub Enterprise endpoint. | `process.env.GH_URL` or `process.env.GITHUB_URL` | +| `githubApiPathPrefix` | The GitHub Enterprise API prefix. | `process.env.GH_PREFIX` or `process.env.GITHUB_PREFIX` | + +## publish + +Publish a [Github release](https://help.github.com/articles/about-releases). + +### Options + +| Option | Description | Default | +| --------------------- | --------------------------------------------------------- | ------------------------------------------------------ | +| `githubToken` | **Required.** The token used to authenticate with GitHub. | `process.env.GH_TOKEN` or `process.env.GITHUB_TOKEN` | +| `githubUrl` | The GitHub Enterprise endpoint. | `process.env.GH_URL` or `process.env.GITHUB_URL` | +| `githubApiPathPrefix` | The GitHub Enterprise API prefix. | `process.env.GH_PREFIX` or `process.env.GITHUB_PREFIX` | +| `assets` | An array of files to upload to the release. | - + +#### assets option + +Each element of the array can be a path to the file or an `object` with the properties: + +| Property | Description | Default | +| -------- | ------------------------------------------------------------------------ | ------------------------------------ | +| `path` | **Required.** The file path to upload relative to the project directory. | - | +| `name` | The name of the downloadable file on the Github release. | File name extracted from the `path`. | +| `label` | Short description of the file displayed on the Github release. | - | + +## Configuration + +The plugins are used by default by [semantic-release](https://github.com/semantic-release/semantic-release) so no specific configuration is requiered if `githubToken`, `githubUrl` and `githubApiPathPrefix` are set via environment variable. + +Each individual plugin can be disabled, replaced or used with other plugins in the `package.json`: +```json +{ + "release": { + "verifyConditions": ["@semantic-release/github", "verify-other-condition"], + "getLastRelease": "custom-get-last-release", + "publish": [ + "custom-publish", + { + "path": "@semantic-release/github", + "assets": [ + {"path": "dist/asset.min.css", "label": "CSS distribution"}, + {"path": "dist/asset.min.js", "label": "JS distribution"} + ] + } + ] + } +} +``` + +The same configuration for Github Enterprise: +```json +{ + "release": { + "verifyConditions": [ + { + "path": "@semantic-release/github", + "githubUrl": "https://my-ghe.com", + "githubApiPathPrefix": "/api-prefix" + }, + "verify-other-condition" + ], + "getLastRelease": "custom-get-last-release", + "publish": [ + "custom-publish", + { + "path": "@semantic-release/github", + "githubUrl": "https://my-ghe.com", + "githubApiPathPrefix": "/api-prefix", + "assets": [ + {"path": "dist/asset.min.css", "label": "CSS distribution"}, + {"path": "dist/asset.min.js", "label": "JS distribution"} + ] + } + ] + } +} +``` diff --git a/index.js b/index.js new file mode 100644 index 00000000..180ad1f2 --- /dev/null +++ b/index.js @@ -0,0 +1,30 @@ +const {callbackify} = require('util'); +const verifyGithub = require('./lib/verify'); +const publishGit = require('./lib/publish'); + +let verified; + +async function verifyConditions(pluginConfig, {pkg, options: {publish}}) { + // If the Github publish plugin is used and has `assets` configured, validate it now in order to prevent any release if the configuration is wrong + if (publish) { + const publishPlugin = (Array.isArray(publish) ? publish : [publish]).find( + config => config.path && config.path === '@semantic-release/github' + ); + if (publishPlugin && publishPlugin.assets) { + pluginConfig.assets = publishPlugin.assets; + } + } + + await verifyGithub(pluginConfig, pkg); + verified = true; +} + +async function publish(pluginConfig, {pkg, nextRelease, options, logger}) { + if (!verified) { + await verifyGithub(pluginConfig, pkg); + verified = true; + } + await publishGit(pluginConfig, options, pkg, nextRelease, logger); +} + +module.exports = {verifyConditions: callbackify(verifyConditions), publish: callbackify(publish)}; diff --git a/lib/publish.js b/lib/publish.js new file mode 100644 index 00000000..3bd14324 --- /dev/null +++ b/lib/publish.js @@ -0,0 +1,58 @@ +const {basename} = require('path'); +const {parse} = require('url'); +const {stat} = require('fs-extra'); +const gitUrlParse = require('git-url-parse'); +const GitHubApi = require('github'); +const resolveConfig = require('./resolve-config'); +const pEachSeries = require('p-each-series'); +const debug = require('debug')('semantic-release:publish-github'); + +module.exports = async (pluginConfig, {branch}, {repository}, {version, gitHead, gitTag, notes}, logger) => { + const {githubToken, githubUrl, githubApiPathPrefix, assets} = resolveConfig(pluginConfig); + const {name: repo, owner} = gitUrlParse(repository.url); + let {port, protocol, hostname: host} = githubUrl ? parse(githubUrl) : {}; + protocol = (protocol || '').split(':')[0] || null; + + const github = new GitHubApi({port, protocol, host, pathPrefix: githubApiPathPrefix}); + github.authenticate({type: 'token', token: githubToken}); + + const release = {owner, repo, tag_name: gitTag, name: gitTag, target_commitish: branch, body: notes}; + debug('release owner: %o', owner); + debug('release repo: %o', repo); + debug('release name: %o', gitTag); + debug('release branch: %o', branch); + const ref = `refs/tags/${gitTag}`; + + debug('Create git tag %o with commit %o', ref, gitHead); + await github.gitdata.createReference({owner, repo, ref, sha: gitHead}); + const {data: {id, html_url}} = await github.repos.createRelease(release); + logger.log('Published Github release: %s', html_url); + + if (assets && assets.length > 0) { + // Make requests serially to avoid hitting the rate limit (https://developer.github.com/v3/guides/best-practices-for-integrators/#dealing-with-abuse-rate-limits) + await pEachSeries(assets, async asset => { + const filePath = typeof asset === 'object' ? asset.path : asset; + let file; + try { + file = await stat(filePath); + } catch (err) { + logger.error('The asset %s cannot be read, and will be ignored.', filePath); + return; + } + if (!file || !file.isFile()) { + logger.error('The asset %s is not a file, and will be ignored.', filePath); + return; + } + const fileName = asset.name || basename(filePath); + const upload = {owner, repo, id, filePath, name: fileName}; + debug('file path: %o', filePath); + debug('file name: %o', fileName); + if (asset.label) { + upload.label = asset.label; + } + + const {data: {browser_download_url}} = await github.repos.uploadAsset(upload); + logger.log('Published file %s', browser_download_url); + }); + } +}; diff --git a/lib/resolve-config.js b/lib/resolve-config.js new file mode 100644 index 00000000..8b3695a4 --- /dev/null +++ b/lib/resolve-config.js @@ -0,0 +1,6 @@ +module.exports = ({githubToken, githubUrl, githubApiPathPrefix, assets}) => ({ + githubToken: githubToken || process.env.GH_TOKEN || process.env.GITHUB_TOKEN, + githubUrl: githubUrl || process.env.GH_URL || process.env.GITHUB_URL, + githubApiPathPrefix: githubApiPathPrefix || process.env.GH_PREFIX || process.env.GITHUB_PREFIX, + assets: assets ? (Array.isArray(assets) ? assets : [assets]) : assets, +}); diff --git a/lib/verify.js b/lib/verify.js new file mode 100644 index 00000000..e117813b --- /dev/null +++ b/lib/verify.js @@ -0,0 +1,56 @@ +const {parse} = require('url'); +const gitUrlParse = require('git-url-parse'); +const GitHubApi = require('github'); +const resolveConfig = require('./resolve-config'); +const SemanticReleaseError = require('@semantic-release/error'); + +module.exports = async (pluginConfig, {name, repository}) => { + const {githubToken, githubUrl, githubApiPathPrefix, assets} = resolveConfig(pluginConfig); + + if (!name) { + throw new SemanticReleaseError('No "name" found in package.json.', 'ENOPKGNAME'); + } + + if (!repository || !repository.url) { + throw new SemanticReleaseError('No "repository" found in package.json.', 'ENOPKGREPO'); + } + + if (!githubToken) { + throw new SemanticReleaseError('No github token specified.', 'ENOGHTOKEN'); + } + + if (assets && assets.length > 0) { + // Verify that every asset is either a string or an object with path attribute defined + if (!assets.every(asset => typeof asset === 'string' || (typeof asset === 'object' && Boolean(asset.path)))) { + throw new SemanticReleaseError( + 'The "assets" options must be an Array of strings or objects with a path property.', + 'EINVALIDASSETS' + ); + } + } + + const {name: repo, owner} = gitUrlParse(repository.url); + let {port, protocol, hostname: host} = githubUrl ? parse(githubUrl) : {}; + protocol = (protocol || '').split(':')[0] || null; + + const github = new GitHubApi({port, protocol, host, pathPrefix: githubApiPathPrefix}); + github.authenticate({type: 'token', token: githubToken}); + + let push; + try { + ({data: {permissions: {push}}} = await github.repos.get({repo, owner})); + } catch (err) { + if (err.code === 401) { + throw new SemanticReleaseError('Invalid Github token.', 'EINVALIDGHTOKEN'); + } else if (err.code === 404) { + throw new SemanticReleaseError(`The repository ${owner}/${repo} doesn't exist.`, 'EMISSINGREPO'); + } + throw err; + } + if (!push) { + throw new SemanticReleaseError( + `The github token doesn't allow to push on the repository ${owner}/${repo}.`, + 'EGHNOPERMISSION' + ); + } +}; diff --git a/package.json b/package.json new file mode 100644 index 00000000..55fc3b40 --- /dev/null +++ b/package.json @@ -0,0 +1,111 @@ +{ + "name": "@semantic-release/github", + "description": "Set of semantic-release plugins for publishing a Github release", + "version": "0.0.0-development", + "author": "Pierre Vanduynslager (https://twitter.com/@pvdlg_)", + "bugs": { + "url": "https://github.com/semantic-release/github/issues" + }, + "config": { + "commitizen": { + "path": "cz-conventional-changelog" + } + }, + "contributors": [ + "Stephan Bönnemann (http://boennemann.me)", + "Gregor Martynus (https://twitter.com/gr2m)" + ], + "dependencies": { + "@semantic-release/error": "^2.1.0", + "debug": "^3.1.0", + "fs-extra": "^4.0.2", + "git-url-parse": "^7.0.1", + "github": "^12.0.3", + "p-each-series": "^1.0.0" + }, + "devDependencies": { + "ava": "^0.23.0", + "clear-module": "^2.1.0", + "codecov": "^3.0.0", + "commitizen": "^2.9.6", + "cz-conventional-changelog": "^2.0.0", + "eslint": "^4.11.0", + "eslint-config-prettier": "^2.8.0", + "eslint-config-standard": "^10.2.1", + "eslint-plugin-import": "^2.7.0", + "eslint-plugin-node": "^5.2.0", + "eslint-plugin-prettier": "^2.3.0", + "eslint-plugin-promise": "^3.5.0", + "eslint-plugin-standard": "^3.0.1", + "nock": "^9.1.0", + "nyc": "^11.2.1", + "prettier": "~1.8.2", + "rimraf": "^2.5.0", + "semantic-release": "^9.1.1", + "sinon": "^4.0.0" + }, + "engines": { + "node": ">=4" + }, + "eslintConfig": { + "extends": [ + "standard", + "prettier" + ], + "plugins": [ + "prettier" + ], + "rules": { + "prettier/prettier": 2 + } + }, + "files": [ + "lib", + "index.js" + ], + "homepage": "https://github.com/semantic-release/github#readme", + "keywords": [ + "git", + "github", + "publish", + "release", + "semantic-release", + "version" + ], + "license": "MIT", + "main": "index.js", + "nyc": { + "include": [ + "lib/**/*.js", + "index.js" + ], + "reporter": [ + "json", + "text", + "html" + ], + "all": true + }, + "prettier": { + "printWidth": 120, + "singleQuote": true, + "bracketSpacing": false, + "trailingComma": "es5" + }, + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "https://github.com/semantic-release/github.git" + }, + "scripts": { + "clean": "rimraf coverage && rimraf .nyc_output", + "cm": "git-cz", + "codecov": "codecov -f coverage/coverage-final.json", + "lint": "eslint test lib index.js", + "pretest": "npm run clean && npm run lint", + "semantic-release": "semantic-release", + "test": "nyc ava -v" + } +} diff --git a/test/fixtures/upload.txt b/test/fixtures/upload.txt new file mode 100644 index 00000000..5e2dd259 --- /dev/null +++ b/test/fixtures/upload.txt @@ -0,0 +1 @@ +Upload file content \ No newline at end of file diff --git a/test/fixtures/upload_other.txt b/test/fixtures/upload_other.txt new file mode 100644 index 00000000..5e2dd259 --- /dev/null +++ b/test/fixtures/upload_other.txt @@ -0,0 +1 @@ +Upload file content \ No newline at end of file diff --git a/test/helpers/mock-github.js b/test/helpers/mock-github.js new file mode 100644 index 00000000..ee013786 --- /dev/null +++ b/test/helpers/mock-github.js @@ -0,0 +1,28 @@ +import nock from 'nock'; + +/** + * Retun a `nock` object setup to respond to a github authentication request. Other expectation and responses can be chained. + * + * @param {String} [githubToken='GH_TOKEN'] The github token to return in the authentication response. + * @param {String} [githubUrl='https://api.github.com'] The url on which to intercept http requests. + * @param {String} [githubApiPathPrefix] The GitHub Enterprise API prefix. + * @return {Object} A `nock` object ready to respond to a github authentication request. + */ +export function authenticate( + {githubToken = 'GH_TOKEN', githubUrl = 'https://api.github.com', githubApiPathPrefix = ''} = {} +) { + return nock(`${githubUrl}/${githubApiPathPrefix}`, {reqheaders: {Authorization: `token ${githubToken}`}}); +} + +/** + * Retun a `nock` object setup to respond to a github release upload request. Other expectation and responses can be chained. + * + * @param {String} [githubToken='GH_TOKEN'] The github token to return in the authentication response. + * @param {String} [githubUrl='https://uploads.github.com'] The url on which to intercept http requests. + * @return {Object} A `nock` object ready to respond to a github file upload request. + */ +export function upload({githubToken = 'GH_TOKEN', githubUrl = 'https://uploads.github.com'} = {}) { + return nock(`${githubUrl}${githubUrl !== 'https://uploads.github.com' ? '/api/uploads' : ''}`, { + reqheaders: {Authorization: `token ${githubToken}`}, + }); +} diff --git a/test/integration.test.js b/test/integration.test.js new file mode 100644 index 00000000..402bbca5 --- /dev/null +++ b/test/integration.test.js @@ -0,0 +1,202 @@ +import {promisify} from 'util'; +import {escape} from 'querystring'; +import test from 'ava'; +import {stub, match} from 'sinon'; +import clearModule from 'clear-module'; +import {authenticate, upload} from './helpers/mock-github'; +import SemanticReleaseError from '@semantic-release/error'; + +test.beforeEach(async t => { + // Save the current process.env + t.context.env = Object.assign({}, process.env); + // Delete env variables in case they are on the machine running the tests + delete process.env.GH_TOKEN; + delete process.env.GITHUB_TOKEN; + delete process.env.GH_URL; + delete process.env.GITHUB_URL; + delete process.env.GH_PREFIX; + delete process.env.GITHUB_PREFIX; + // Clear npm cache to refresh the module state + clearModule('../index'); + t.context.m = require('../index'); + // Stub the logger + t.context.log = stub(); + t.context.error = stub(); + t.context.logger = {log: t.context.log, error: t.context.error}; +}); + +test.afterEach.always(async t => { + // Restore process.env + process.env = Object.assign({}, t.context.env); +}); + +test.serial('Verify Github auth', async t => { + process.env.GITHUB_TOKEN = 'github_token'; + const owner = 'test_user'; + const repo = 'test_repo'; + const options = {}; + const pkg = {name: 'package-name', repository: {url: `git+https://othertesturl.com/${owner}/${repo}.git`}}; + + const github = authenticate({githubToken: process.env.GITHUB_TOKEN}) + .get(`/repos/${owner}/${repo}`) + .reply(200, {permissions: {push: true}}); + + await t.notThrows(promisify(t.context.m.verifyConditions)({}, {pkg, options})); + + t.true(github.isDone()); +}); + +test.serial('Verify Github auth with publish options', async t => { + process.env.GITHUB_TOKEN = 'github_token'; + const owner = 'test_user'; + const repo = 'test_repo'; + const options = {publish: {path: '@semantic-release/github'}}; + const pkg = {name: 'package-name', repository: {url: `git+https://othertesturl.com/${owner}/${repo}.git`}}; + + const github = authenticate({githubToken: process.env.GITHUB_TOKEN}) + .get(`/repos/${owner}/${repo}`) + .reply(200, {permissions: {push: true}}); + + await t.notThrows(promisify(t.context.m.verifyConditions)({}, {pkg, options})); + + t.true(github.isDone()); +}); + +test.serial('Verify Github auth and assets config', async t => { + process.env.GH_TOKEN = 'github_token'; + const owner = 'test_user'; + const repo = 'test_repo'; + const assets = [{path: 'lib/file.js'}, 'file.js']; + const options = {publish: [{path: '@semantic-release/npm'}, {path: '@semantic-release/github', assets}]}; + const pkg = {name: 'package-name', repository: {url: `git+https://othertesturl.com/${owner}/${repo}.git`}}; + + const github = authenticate({githubToken: process.env.GH_TOKEN}) + .get(`/repos/${owner}/${repo}`) + .reply(200, {permissions: {push: true}}); + + await t.notThrows(promisify(t.context.m.verifyConditions)({}, {pkg, options})); + + t.true(github.isDone()); +}); + +test.serial('Throw SemanticReleaseError if invalid config', async t => { + process.env.GH_TOKEN = 'github_token'; + const owner = 'test_user'; + const repo = 'test_repo'; + const assets = [{wrongProperty: 'lib/file.js'}]; + const options = {publish: [{path: '@semantic-release/npm'}, {path: '@semantic-release/github', assets}]}; + const pkg = {name: 'package-name', repository: {url: `git+https://othertesturl.com/${owner}/${repo}.git`}}; + + const error = await t.throws(promisify(t.context.m.verifyConditions)({}, {pkg, options})); + + t.true(error instanceof SemanticReleaseError); + t.is(error.code, 'EINVALIDASSETS'); +}); + +test.serial('Publish a release with an array of assets', async t => { + const owner = 'test_user'; + const repo = 'test_repo'; + const githubToken = 'github_token'; + const assets = [ + 'test/fixtures/upload.txt', + {path: 'test/fixtures/upload_other.txt', name: 'other_file.txt', label: 'Other File'}, + ]; + const nextRelease = {version: '1.0.0', gitHead: '123', gitTag: 'v1.0.0', notes: 'Test release note body'}; + const options = {branch: 'master'}; + const pkg = {name: 'package-name', repository: {url: `https://github.com/${owner}/${repo}.git`}}; + const releaseUrl = `https://github.com/${owner}/${repo}/releases/${nextRelease.version}`; + const assetUrl = `https://github.com/${owner}/${repo}/releases/download/${nextRelease.version}/upload.txt`; + const otherAssetUrl = `https://github.com/${owner}/${repo}/releases/download/${nextRelease.version}/other_file.txt`; + const releaseId = 1; + + const github = authenticate({githubToken}) + .get(`/repos/${owner}/${repo}`) + .reply(200, {permissions: {push: true}}) + .post(`/repos/${owner}/${repo}/releases`, { + tag_name: nextRelease.gitTag, + target_commitish: options.branch, + name: nextRelease.gitTag, + body: nextRelease.notes, + }) + .reply(200, {html_url: releaseUrl, id: releaseId}) + .post(`/repos/${owner}/${repo}/git/refs`, {ref: `refs/tags/${nextRelease.gitTag}`, sha: nextRelease.gitHead}) + .reply({}); + + const githubUpload = upload({githubToken}) + .post( + `/repos/${owner}/${repo}/releases/${releaseId}/assets?filePath=${escape( + 'test/fixtures/upload.txt' + )}&name=${escape('upload.txt')}` + ) + .reply(200, {browser_download_url: assetUrl}) + .post( + `/repos/${owner}/${repo}/releases/${releaseId}/assets?filePath=${escape( + 'test/fixtures/upload_other.txt' + )}&name=${escape('other_file.txt')}&label=${escape('Other File')}` + ) + .reply(200, {browser_download_url: otherAssetUrl}); + + await promisify(t.context.m.publish)({githubToken, assets}, {pkg, nextRelease, options, logger: t.context.logger}); + + t.true(t.context.log.calledWith(match.string, releaseUrl)); + t.true(t.context.log.calledWith(match.string, assetUrl)); + t.true(t.context.log.calledWith(match.string, otherAssetUrl)); + t.true(github.isDone()); + t.true(githubUpload.isDone()); +}); + +test.serial('Verify Github auth and release', async t => { + process.env.GH_TOKEN = 'github_token'; + const owner = 'test_user'; + const repo = 'test_repo'; + const assets = [ + 'test/fixtures/upload.txt', + {path: 'test/fixtures/upload_other.txt', name: 'other_file.txt', label: 'Other File'}, + ]; + const pkg = {name: 'package-name', repository: {url: `https://github.com/${owner}/${repo}.git`}}; + const options = { + publish: [{path: '@semantic-release/npm'}, {path: '@semantic-release/github', assets}], + branch: 'master', + }; + const nextRelease = {version: '1.0.0', gitHead: '123', gitTag: 'v1.0.0', notes: 'Test release note body'}; + const releaseUrl = `https://github.com/${owner}/${repo}/releases/${nextRelease.version}`; + const assetUrl = `https://github.com/${owner}/${repo}/releases/download/${nextRelease.version}/upload.txt`; + const otherAssetUrl = `https://github.com/${owner}/${repo}/releases/download/${nextRelease.version}/other_file.txt`; + const releaseId = 1; + + const github = authenticate({githubToken: process.env.GH_TOKEN}) + .get(`/repos/${owner}/${repo}`) + .reply(200, {permissions: {push: true}}) + .post(`/repos/${owner}/${repo}/releases`, { + tag_name: nextRelease.gitTag, + target_commitish: options.branch, + name: nextRelease.gitTag, + body: nextRelease.notes, + }) + .reply(200, {html_url: releaseUrl, id: releaseId}) + .post(`/repos/${owner}/${repo}/git/refs`, {ref: `refs/tags/${nextRelease.gitTag}`, sha: nextRelease.gitHead}) + .reply({}); + + const githubUpload = upload({githubToken: process.env.GH_TOKEN}) + .post( + `/repos/${owner}/${repo}/releases/${releaseId}/assets?filePath=${escape( + 'test/fixtures/upload.txt' + )}&name=${escape('upload.txt')}` + ) + .reply(200, {browser_download_url: assetUrl}) + .post( + `/repos/${owner}/${repo}/releases/${releaseId}/assets?filePath=${escape( + 'test/fixtures/upload_other.txt' + )}&name=${escape('other_file.txt')}&label=${escape('Other File')}` + ) + .reply(200, {browser_download_url: otherAssetUrl}); + + await t.notThrows(promisify(t.context.m.verifyConditions)({}, {pkg, options})); + await promisify(t.context.m.publish)({assets}, {pkg, nextRelease, options, logger: t.context.logger}); + + t.true(t.context.log.calledWith(match.string, releaseUrl)); + t.true(t.context.log.calledWith(match.string, assetUrl)); + t.true(t.context.log.calledWith(match.string, otherAssetUrl)); + t.true(github.isDone()); + t.true(githubUpload.isDone()); +}); diff --git a/test/publish.test.js b/test/publish.test.js new file mode 100644 index 00000000..7ad04a99 --- /dev/null +++ b/test/publish.test.js @@ -0,0 +1,233 @@ +import {escape} from 'querystring'; +import test from 'ava'; +import nock from 'nock'; +import {stub, match} from 'sinon'; +import {authenticate, upload} from './helpers/mock-github'; +import publish from '../lib/publish'; + +test.beforeEach(async t => { + // Save the current process.env + t.context.env = Object.assign({}, process.env); + // Delete env variables in case they are on the machine running the tests + delete process.env.GH_TOKEN; + delete process.env.GITHUB_TOKEN; + delete process.env.GH_URL; + delete process.env.GITHUB_URL; + delete process.env.GH_PREFIX; + delete process.env.GITHUB_PREFIX; + // Mock logger + t.context.log = stub(); + t.context.error = stub(); + t.context.logger = {log: t.context.log, error: t.context.error}; +}); + +test.afterEach.always(t => { + // Restore process.env + process.env = Object.assign({}, t.context.env); + // Clear nock + nock.cleanAll(); +}); + +test.serial('Publish a release', async t => { + const owner = 'test_user'; + const repo = 'test_repo'; + const githubToken = 'github_token'; + const pluginConfig = {githubToken}; + const nextRelease = {version: '1.0.0', gitHead: '123', gitTag: 'v1.0.0', notes: 'Test release note body'}; + const options = {branch: 'master'}; + const pkg = {repository: {url: `https://github.com/${owner}/${repo}.git`}}; + const releaseUrl = `https://github.com/${owner}/${repo}/releases/${nextRelease.version}`; + + const github = authenticate({githubToken}) + .post(`/repos/${owner}/${repo}/releases`, { + tag_name: nextRelease.gitTag, + target_commitish: options.branch, + name: nextRelease.gitTag, + body: nextRelease.notes, + }) + .reply(200, {html_url: releaseUrl}) + .post(`/repos/${owner}/${repo}/git/refs`, {ref: `refs/tags/${nextRelease.gitTag}`, sha: nextRelease.gitHead}) + .reply({}); + + await publish(pluginConfig, options, pkg, nextRelease, t.context.logger); + + t.true(t.context.log.calledWith(match.string, releaseUrl)); + t.true(github.isDone()); +}); + +test.serial('Publish a release with one asset', async t => { + const owner = 'test_user'; + const repo = 'test_repo'; + process.env.GH_URL = 'https://othertesturl.com:443'; + process.env.GH_TOKEN = 'github_token'; + process.env.GH_PREFIX = 'prefix'; + const pluginConfig = {assets: 'test/fixtures/upload.txt'}; + const nextRelease = {version: '1.0.0', gitHead: '123', gitTag: 'v1.0.0', notes: 'Test release note body'}; + const options = {branch: 'master'}; + const pkg = {repository: {url: `https://github.com/${owner}/${repo}.git`}}; + const releaseUrl = `https://github.com/${owner}/${repo}/releases/${nextRelease.version}`; + const assetUrl = `https://github.com/${owner}/${repo}/releases/download/${nextRelease.version}/upload.txt`; + const releaseId = 1; + + const github = authenticate({ + githubUrl: process.env.GH_URL, + githubToken: process.env.GH_TOKEN, + githubApiPathPrefix: process.env.GH_PREFIX, + }) + .post(`/repos/${owner}/${repo}/releases`, { + tag_name: nextRelease.gitTag, + target_commitish: options.branch, + name: nextRelease.gitTag, + body: nextRelease.notes, + }) + .reply(200, {html_url: releaseUrl, id: releaseId}) + .post(`/repos/${owner}/${repo}/git/refs`, {ref: `refs/tags/${nextRelease.gitTag}`, sha: nextRelease.gitHead}) + .reply({}); + + const githubUpload = upload({githubUrl: process.env.GH_URL, githubToken: process.env.GH_TOKEN}) + .post( + `/repos/${owner}/${repo}/releases/${releaseId}/assets?filePath=${escape( + 'test/fixtures/upload.txt' + )}&name=${escape('upload.txt')}` + ) + .reply(200, {browser_download_url: assetUrl}); + + await publish(pluginConfig, options, pkg, nextRelease, t.context.logger); + + t.true(t.context.log.calledWith(match.string, releaseUrl)); + t.true(t.context.log.calledWith(match.string, assetUrl)); + t.true(github.isDone()); + t.true(githubUpload.isDone()); +}); + +test.serial('Publish a release with one asset and custom github url', async t => { + const owner = 'test_user'; + const repo = 'test_repo'; + process.env.GITHUB_URL = 'https://othertesturl.com:443'; + process.env.GITHUB_TOKEN = 'github_token'; + process.env.GITHUB_PREFIX = 'prefix'; + const assets = 'test/fixtures/upload.txt'; + const pluginConfig = {githubUrl: process.env.GITHUB_URL, githubApiPathPrefix: process.env.GITHUB_PREFIX, assets}; + const nextRelease = {version: '1.0.0', gitHead: '123', gitTag: 'v1.0.0', notes: 'Test release note body'}; + const options = {branch: 'master'}; + const pkg = {repository: {url: `https://github.com/${owner}/${repo}.git`}}; + const releaseUrl = `https://github.com/${owner}/${repo}/releases/${nextRelease.version}`; + const assetUrl = `https://github.com/${owner}/${repo}/releases/download/${nextRelease.version}/upload.txt`; + const releaseId = 1; + + const github = authenticate({ + githubToken: process.env.GITHUB_TOKEN, + githubUrl: process.env.GITHUB_URL, + githubApiPathPrefix: process.env.GITHUB_PREFIX, + }) + .post(`/repos/${owner}/${repo}/releases`, { + tag_name: nextRelease.gitTag, + target_commitish: options.branch, + name: nextRelease.gitTag, + body: nextRelease.notes, + }) + .reply(200, {html_url: releaseUrl, id: releaseId}) + .post(`/repos/${owner}/${repo}/git/refs`, {ref: `refs/tags/${nextRelease.gitTag}`, sha: nextRelease.gitHead}) + .reply({}); + + const githubUpload = upload({githubToken: process.env.GITHUB_TOKEN, githubUrl: process.env.GITHUB_URL}) + .post( + `/repos/${owner}/${repo}/releases/${releaseId}/assets?filePath=${escape( + 'test/fixtures/upload.txt' + )}&name=${escape('upload.txt')}` + ) + .reply(200, {browser_download_url: assetUrl}); + + await publish(pluginConfig, options, pkg, nextRelease, t.context.logger); + + t.true(t.context.log.calledWith(match.string, releaseUrl)); + t.true(t.context.log.calledWith(match.string, assetUrl)); + t.true(github.isDone()); + t.true(githubUpload.isDone()); +}); + +test.serial('Publish a release with an array of assets', async t => { + const owner = 'test_user'; + const repo = 'test_repo'; + const githubToken = 'github_token'; + const pluginConfig = { + githubToken, + assets: [ + 'test/fixtures/upload.txt', + {path: 'test/fixtures/upload_other.txt', name: 'other_file.txt', label: 'Other File'}, + ], + }; + const nextRelease = {version: '1.0.0', gitHead: '123', gitTag: 'v1.0.0', notes: 'Test release note body'}; + const options = {branch: 'master'}; + const pkg = {repository: {url: `https://github.com/${owner}/${repo}.git`}}; + const releaseUrl = `https://github.com/${owner}/${repo}/releases/${nextRelease.version}`; + const assetUrl = `https://github.com/${owner}/${repo}/releases/download/${nextRelease.version}/upload.txt`; + const otherAssetUrl = `https://github.com/${owner}/${repo}/releases/download/${nextRelease.version}/other_file.txt`; + const releaseId = 1; + + const github = authenticate({githubToken}) + .post(`/repos/${owner}/${repo}/releases`, { + tag_name: nextRelease.gitTag, + target_commitish: options.branch, + name: nextRelease.gitTag, + body: nextRelease.notes, + }) + .reply(200, {html_url: releaseUrl, id: releaseId}) + .post(`/repos/${owner}/${repo}/git/refs`, {ref: `refs/tags/${nextRelease.gitTag}`, sha: nextRelease.gitHead}) + .reply({}); + + const githubUpload = upload({githubToken}) + .post( + `/repos/${owner}/${repo}/releases/${releaseId}/assets?filePath=${escape( + 'test/fixtures/upload.txt' + )}&name=${escape('upload.txt')}` + ) + .reply(200, {browser_download_url: assetUrl}) + .post( + `/repos/${owner}/${repo}/releases/${releaseId}/assets?filePath=${escape( + 'test/fixtures/upload_other.txt' + )}&name=${escape('other_file.txt')}&label=${escape('Other File')}` + ) + .reply(200, {browser_download_url: otherAssetUrl}); + + await publish(pluginConfig, options, pkg, nextRelease, t.context.logger); + + t.true(t.context.log.calledWith(match.string, releaseUrl)); + t.true(t.context.log.calledWith(match.string, assetUrl)); + t.true(t.context.log.calledWith(match.string, otherAssetUrl)); + t.true(github.isDone()); + t.true(githubUpload.isDone()); +}); + +test.serial('Publish a release with an array of misconfigured assets', async t => { + const owner = 'test_user'; + const repo = 'test_repo'; + const githubToken = 'github_token'; + const pluginConfig = { + githubToken, + assets: ['test/fixtures', {path: 'test/fixtures/missing.txt', name: 'missing.txt', label: 'Missing File'}], + }; + const nextRelease = {version: '1.0.0', gitHead: '123', gitTag: 'v1.0.0', notes: 'Test release note body'}; + const options = {branch: 'master'}; + const pkg = {repository: {url: `https://github.com/${owner}/${repo}.git`}}; + const releaseUrl = `https://github.com/${owner}/${repo}/releases/${nextRelease.version}`; + const releaseId = 1; + + const github = authenticate({githubToken}) + .post(`/repos/${owner}/${repo}/releases`, { + tag_name: nextRelease.gitTag, + target_commitish: options.branch, + name: nextRelease.gitTag, + body: nextRelease.notes, + }) + .reply(200, {html_url: releaseUrl, id: releaseId}) + .post(`/repos/${owner}/${repo}/git/refs`, {ref: `refs/tags/${nextRelease.gitTag}`, sha: nextRelease.gitHead}) + .reply({}); + + await publish(pluginConfig, options, pkg, nextRelease, t.context.logger); + + t.true(t.context.log.calledWith(match.string, releaseUrl)); + t.true(t.context.error.calledWith(match.string, 'test/fixtures/missing.txt')); + t.true(t.context.error.calledWith(match.string, 'test/fixtures')); + t.true(github.isDone()); +}); diff --git a/test/verify.test.js b/test/verify.test.js new file mode 100644 index 00000000..90c77e29 --- /dev/null +++ b/test/verify.test.js @@ -0,0 +1,271 @@ +import test from 'ava'; +import nock from 'nock'; +import SemanticReleaseError from '@semantic-release/error'; +import {authenticate} from './helpers/mock-github'; +import verify from '../lib/verify'; + +test.beforeEach(async t => { + // Save the current process.env + t.context.env = Object.assign({}, process.env); + // Delete env variables in case they are on the machine running the tests + delete process.env.GH_TOKEN; + delete process.env.GITHUB_TOKEN; + delete process.env.GH_URL; + delete process.env.GITHUB_URL; + delete process.env.GH_PREFIX; + delete process.env.GITHUB_PREFIX; +}); + +test.afterEach.always(t => { + // Restore process.env + process.env = Object.assign({}, t.context.env); + // Clear nock + nock.cleanAll(); +}); + +test.serial('Verify package, token and repository access', async t => { + const owner = 'test_user'; + const repo = 'test_repo'; + const githubToken = 'github_token'; + const assets = [{path: 'lib/file.js'}, 'file.js']; + + const github = authenticate({githubToken}) + .get(`/repos/${owner}/${repo}`) + .reply(200, {permissions: {push: true}}); + + await t.notThrows( + verify( + {githubToken, assets}, + {name: 'package-name', repository: {url: `git+https://othertesturl.com/${owner}/${repo}.git`}} + ) + ); + t.true(github.isDone()); +}); + +test.serial('Verify package, token and repository access and custom URL', async t => { + const owner = 'test_user'; + const repo = 'test_repo'; + const githubUrl = 'https://othertesturl.com:443'; + const githubToken = 'github_token'; + const githubApiPathPrefix = 'prefix'; + + const github = authenticate({githubUrl, githubToken, githubApiPathPrefix}) + .get(`/repos/${owner}/${repo}`) + .reply(200, {permissions: {push: true}}); + + await t.notThrows( + verify( + {githubUrl, githubToken, githubApiPathPrefix}, + {name: 'package-name', repository: {url: `git@othertesturl.com:${owner}/${repo}.git`}} + ) + ); + t.true(github.isDone()); +}); + +test.serial('Verify package, token and repository with environment varialbes', async t => { + const owner = 'test_user'; + const repo = 'test_repo'; + process.env.GH_URL = 'https://othertesturl.com:443'; + process.env.GH_TOKEN = 'github_token'; + process.env.GH_PREFIX = 'prefix'; + + const github = authenticate({ + githubUrl: process.env.GH_URL, + githubToken: process.env.GH_TOKEN, + githubApiPathPrefix: process.env.GH_PREFIX, + }) + .get(`/repos/${owner}/${repo}`) + .reply(200, {permissions: {push: true}}); + + await t.notThrows( + verify( + {githubUrl: process.env.GH_URL, githubApiPathPrefix: process.env.GH_PREFIX}, + {name: 'package-name', repository: {url: `git@othertesturl.com:${owner}/${repo}.git`}} + ) + ); + t.true(github.isDone()); +}); + +test.serial('Verify package, token and repository access with alternative environment varialbes', async t => { + const owner = 'test_user'; + const repo = 'test_repo'; + process.env.GITHUB_URL = 'https://othertesturl.com:443'; + process.env.GITHUB_TOKEN = 'github_token'; + process.env.GITHUB_PREFIX = 'prefix'; + + const github = authenticate({ + githubUrl: process.env.GITHUB_URL, + githubToken: process.env.GITHUB_TOKEN, + githubApiPathPrefix: process.env.GITHUB_PREFIX, + }) + .get(`/repos/${owner}/${repo}`) + .reply(200, {permissions: {push: true}}); + + await t.notThrows( + verify( + {githubUrl: process.env.GITHUB_URL, githubApiPathPrefix: process.env.GITHUB_PREFIX}, + {name: 'package-name', repository: {url: `git@othertesturl.com:${owner}/${repo}.git`}} + ) + ); + t.true(github.isDone()); +}); + +test.serial('Throw SemanticReleaseError for missing package name', async t => { + const error = await t.throws(verify({}, {repository: {url: 'http://github.com/whats/up.git'}})); + + t.true(error instanceof SemanticReleaseError); + t.is(error.code, 'ENOPKGNAME'); +}); + +test.serial('Throw SemanticReleaseError for missing repository', async t => { + const error = await t.throws(verify({}, {name: 'package'})); + + t.true(error instanceof SemanticReleaseError); + t.is(error.code, 'ENOPKGREPO'); +}); + +test.serial('Throw SemanticReleaseError for missing repository url', async t => { + const error = await t.throws(verify({}, {name: 'package', repository: {}})); + + t.true(error instanceof SemanticReleaseError); + t.is(error.code, 'ENOPKGREPO'); +}); + +test.serial('Throw SemanticReleaseError if "assets" option is not a string or an array of objects', async t => { + const githubToken = 'github_token'; + const assets = 42; + const error = await t.throws( + verify( + {githubToken, assets}, + {name: 'package', repository: {url: 'https://github.com/semantic-release/github.git'}} + ) + ); + + t.true(error instanceof SemanticReleaseError); + t.is(error.code, 'EINVALIDASSETS'); +}); + +test.serial('Throw SemanticReleaseError if "assets" option is an Array with invalid elements', async t => { + const githubToken = 'github_token'; + const assets = ['file.js', 42]; + const error = await t.throws( + verify( + {githubToken, assets}, + {name: 'package', repository: {url: 'https://github.com/semantic-release/github.git'}} + ) + ); + + t.true(error instanceof SemanticReleaseError); + t.is(error.code, 'EINVALIDASSETS'); +}); + +test.serial('Throw SemanticReleaseError if "assets" option is an Object missing the "path" property', async t => { + const githubToken = 'github_token'; + const assets = {name: 'file.js'}; + const error = await t.throws( + verify( + {githubToken, assets}, + {name: 'package', repository: {url: 'https://github.com/semantic-release/github.git'}} + ) + ); + + t.true(error instanceof SemanticReleaseError); + t.is(error.code, 'EINVALIDASSETS'); +}); + +test.serial( + 'Throw SemanticReleaseError if "assets" option is an Array with objects missing the "path" property', + async t => { + const githubToken = 'github_token'; + const assets = [{path: 'lib/file.js'}, {name: 'file.js'}]; + const error = await t.throws( + verify( + {githubToken, assets}, + {name: 'package', repository: {url: 'https://github.com/semantic-release/github.git'}} + ) + ); + + t.true(error instanceof SemanticReleaseError); + t.is(error.code, 'EINVALIDASSETS'); + } +); + +test.serial('Throw SemanticReleaseError for missing github token', async t => { + const error = await t.throws( + verify({}, {name: 'package', repository: {url: 'https://github.com/semantic-release/github.git'}}) + ); + + t.true(error instanceof SemanticReleaseError); + t.is(error.code, 'ENOGHTOKEN'); +}); + +test.serial('Throw SemanticReleaseError for invalid token', async t => { + const owner = 'test_user'; + const repo = 'test_repo'; + const githubToken = 'github_token'; + + const github = authenticate({githubToken}) + .get(`/repos/${owner}/${repo}`) + .reply(401); + + const error = await t.throws( + verify({githubToken}, {name: 'package-name', repository: {url: `https://github.com:${owner}/${repo}.git`}}) + ); + + t.true(error instanceof SemanticReleaseError); + t.is(error.code, 'EINVALIDGHTOKEN'); + t.true(github.isDone()); +}); + +test.serial("Throw SemanticReleaseError if token doesn't have the push permission on the repository", async t => { + const owner = 'test_user'; + const repo = 'test_repo'; + const githubToken = 'github_token'; + + const github = authenticate({githubToken}) + .get(`/repos/${owner}/${repo}`) + .reply(200, {permissions: {push: false}}); + + const error = await t.throws( + verify({githubToken}, {name: 'package-name', repository: {url: `https://github.com:${owner}/${repo}.git`}}) + ); + + t.true(error instanceof SemanticReleaseError); + t.is(error.code, 'EGHNOPERMISSION'); + t.true(github.isDone()); +}); + +test.serial("Throw SemanticReleaseError if the repository doesn't exist", async t => { + const owner = 'test_user'; + const repo = 'test_repo'; + const githubToken = 'github_token'; + + const github = authenticate({githubToken}) + .get(`/repos/${owner}/${repo}`) + .reply(404); + + const error = await t.throws( + verify({githubToken}, {name: 'package-name', repository: {url: `https://github.com:${owner}/${repo}.git`}}) + ); + + t.true(error instanceof SemanticReleaseError); + t.is(error.code, 'EMISSINGREPO'); + t.true(github.isDone()); +}); + +test.serial('Throw error if github return any other errors', async t => { + const owner = 'test_user'; + const repo = 'test_repo'; + const githubToken = 'github_token'; + + const github = authenticate({githubToken}) + .get(`/repos/${owner}/${repo}`) + .reply(500); + + const error = await t.throws( + verify({githubToken}, {name: 'package-name', repository: {url: `https://github.com:${owner}/${repo}.git`}}) + ); + + t.is(error.code, 500); + t.true(github.isDone()); +});