const fs = require('fs'); const path = require('path'); const { Octokit } = require('@octokit/rest'); const globby = require('globby'); const mime = require('mime-types'); const _ = require('lodash'); const retry = require('async-retry'); const pkg = require('../../../package.json'); const { format, parseVersion, e } = require('../../util'); const Release = require('../GitRelease'); const prompts = require('./prompts'); const docs = 'https://git.io/release-it-github'; const RETRY_CODES = [408, 413, 429, 500, 502, 503, 504, 521, 522, 524]; const DEFAULT_RETRY_MIN_TIMEOUT = 1000; const parseErrormsg = err => { let msg = err; if (err instanceof Error) { const { status, message, headers } = err; msg = `${_.get(headers, 'status', status)} (${message})`; } return msg; }; class GitHub extends Release { constructor(...args) { super(...args); this.registerPrompts(prompts); } async init() { const { skipChecks, tokenRef, isUpdate } = this.options; await super.init(); if (!skipChecks) { if (!this.token) { throw e(`Environment variable "${tokenRef}" is required for GitHub releases.`, docs); } // If we're running on GitHub Actions, we can skip the authentication and // collaborator checks. Ref: https://bit.ly/2vsyRzu if (process.env.GITHUB_ACTIONS) { this.setContext({ username: process.env.GITHUB_ACTOR }); } else { if (!(await this.isAuthenticated())) { throw e(`Could not authenticate with GitHub using environment variable "${tokenRef}".`, docs); } if (!(await this.isCollaborator())) { const { repository } = this.getContext('repo'); const { username } = this.getContext(); throw e(`User ${username} is not a collaborator for ${repository}.`, docs); } } } if (isUpdate) { try { const { id, upload_url } = await this.getLatestRelease(); this.setContext({ isUpdate: true, isReleased: true, releaseId: id, upload_url }); } catch (error) { const { latestTagName } = this.getContext(); this.log.warn(`GitHub release for tag ${latestTagName} was not found. Creating new release.`); this.setContext({ isUpdate: false }); } } } async isAuthenticated() { if (this.config.isDryRun) return true; try { this.log.verbose('octokit users#getAuthenticated'); const { data } = await this.client.users.getAuthenticated(); this.setContext({ username: data.login }); return true; } catch (error) { this.debug(error); return false; } } async isCollaborator() { if (this.config.isDryRun) return true; const { owner, project: repo } = this.getContext('repo'); const { username } = this.getContext(); try { const options = { owner, repo, username }; this.log.verbose(`octokit repos#checkCollaborator (${username})`); await this.client.repos.checkCollaborator(options); return true; } catch (error) { this.debug(error); return false; } } async release() { const { assets } = this.options; const { isUpdate } = this.getContext(); const { isCI } = this.config; const type = isUpdate ? 'update' : 'create'; const publishMethod = `${type}Release`; if (isCI) { await this.step({ task: () => this[publishMethod](), label: `GitHub ${type} release` }); return this.step({ enabled: assets, task: () => this.uploadAssets(), label: 'GitHub upload assets' }); } else { const release = async () => { await this[publishMethod](); return this.uploadAssets(); }; return this.step({ task: release, label: `GitHub ${type} release`, prompt: 'release' }); } } handleError(err, bail) { const message = parseErrormsg(err); const githubError = new Error(message); this.log.verbose(err.errors); this.debug(err); if (!_.includes(RETRY_CODES, err.status)) { return bail(githubError); } throw githubError; } get client() { if (this._client) return this._client; const { proxy, timeout } = this.options; const host = this.options.host || this.getContext('repo.host'); const isGitHub = host === 'github.com'; const baseUrl = `https://${isGitHub ? 'api.github.com' : host}${isGitHub ? '' : '/api/v3'}`; const options = { baseUrl, auth: `token ${this.token}`, userAgent: `release-it/${pkg.version}`, log: this.config.isDebug ? console : null, request: { timeout } }; if (proxy) { options.proxy = proxy; } const client = new Octokit(options); this._client = client; return client; } async getLatestRelease() { const { owner, project: repo } = this.getContext('repo'); try { const options = { owner, repo }; this.debug(options); const response = await this.client.repos.listReleases({ owner, repo, per_page: 1, page: 1 }); this.debug(response.data[0]); return response.data[0]; } catch (err) { return this.handleError(err, () => {}); } } getOctokitReleaseOptions(options = {}) { const { owner, project: repo } = this.getContext('repo'); const { releaseName, draft = false, preRelease = false } = this.options; const { version, tagName, releaseNotes } = this.getContext(); const { isPreRelease } = parseVersion(version); const name = format(releaseName, this.config.getContext()); const body = releaseNotes; return Object.assign(options, { owner, repo, tag_name: tagName, name, body, draft, prerelease: isPreRelease || preRelease }); } retry(fn) { const { retryMinTimeout } = this.options; return retry(fn, { retries: 2, minTimeout: typeof retryMinTimeout === 'number' ? retryMinTimeout : DEFAULT_RETRY_MIN_TIMEOUT }); } async createRelease() { const options = this.getOctokitReleaseOptions(); const { isDryRun } = this.config; this.log.exec(`octokit repos.createRelease "${options.name}" (${options.tag_name})`, { isDryRun }); if (isDryRun) { this.setContext({ isReleased: true, releaseUrl: this.getReleaseUrlFallback(options.tag_name) }); return true; } return this.retry(async bail => { try { this.debug(options); const response = await this.client.repos.createRelease(options); this.debug(response.data); const { html_url, upload_url, id } = response.data; this.setContext({ isReleased: true, releaseId: id, releaseUrl: html_url, upload_url }); this.log.verbose(`octokit repos.createRelease: done (${response.headers.location})`); return response.data; } catch (err) { return this.handleError(err, bail); } }); } uploadAsset(filePath) { const url = this.getContext('upload_url'); const name = path.basename(filePath); const contentType = mime.contentType(name) || 'application/octet-stream'; const contentLength = fs.statSync(filePath).size; return this.retry(async bail => { try { const options = { url, data: fs.createReadStream(filePath), name, headers: { 'content-type': contentType, 'content-length': contentLength } }; this.debug(options); const response = await this.client.repos.uploadReleaseAsset(options); this.debug(response.data); this.log.verbose(`octokit repos.uploadReleaseAsset: done (${response.data.browser_download_url})`); return response.data; } catch (err) { return this.handleError(err, bail); } }); } uploadAssets() { const { assets } = this.options; const { isReleased } = this.getContext(); const { isDryRun } = this.config; this.log.exec('octokit repos.uploadReleaseAssets', assets, { isDryRun }); if (!assets || !isReleased || isDryRun) { return true; } return globby(assets).then(files => { if (!files.length) { this.log.warn(`octokit repos.uploadReleaseAssets: did not find "${assets}" relative to ${process.cwd()}`); } return Promise.all(files.map(filePath => this.uploadAsset(filePath))); }); } getReleaseUrlFallback(tagName) { const { host, repository } = this.getContext('repo'); return `https://${host}/${repository}/releases/tag/${tagName}`; } updateRelease() { const { isDryRun } = this.config; const release_id = this.getContext('releaseId'); const options = this.getOctokitReleaseOptions({ release_id }); this.log.exec(`octokit repos.updateRelease (${options.tag_name})`, { isDryRun }); if (isDryRun) return true; return this.retry(async bail => { try { this.debug(options); const response = await this.client.repos.updateRelease(options); this.setContext({ releaseUrl: response.data.html_url }); this.debug(response.data); this.log.verbose(`octokit repos.updateRelease: done (${response.headers.location})`); return true; } catch (err) { return this.handleError(err, bail); } }); } } module.exports = GitHub;