diff --git a/bin/sake.js b/bin/sake.js index b8e856d..254434c 100755 --- a/bin/sake.js +++ b/bin/sake.js @@ -28,4 +28,9 @@ const args = [ ])) // fire up gulp -spawn('node', args, { cwd: process.cwd(), stdio: 'inherit' }) +const child = spawn('node', args, { cwd: process.cwd(), stdio: 'inherit' }) + +// we need the exit code of the child process to propagate up to the main process +child.on('exit', function(code) { + process.exitCode = code +}) diff --git a/helpers/arguments.js b/helpers/arguments.js new file mode 100644 index 0000000..72e9c5b --- /dev/null +++ b/helpers/arguments.js @@ -0,0 +1,65 @@ +import minimist from 'minimist' + +/** + * Determines if the command is being run in "non-interactive mode". If true, we should never present with prompts. + * @returns {boolean} + */ +export function isNonInteractive() +{ + return process.argv.includes('--non-interactive'); +} + +/** + * Whether we already have a GitHub release for this deployment. If we don't, we'll be creating one. + * @returns {boolean} + */ +export function hasGitRelease() +{ + return !! gitReleaseUploadUrl; +} + +export const gitReleaseUploadUrl = () => { + const argv = minimist(process.argv.slice(2)) + + return argv['release-upload-url'] || null; +} + +/** + * Gets the name of the GitHub "release tag" to deploy. + * @returns {string|null} + */ +export const gitReleaseTag = () => { + const argv = minimist(process.argv.slice(2)) + + return argv['release-tag'] || null; +} + +/** + * Whether this is a dry run deployment. If true, the deploy will not actually happen. + * @returns {boolean} + */ +export function isDryRunDeploy() +{ + return process.argv.includes('--dry-run'); +} + +/** + * If specified, then no changes will be made/committed to the code base during a deployment. This should be used if + * you're specifying an _exact_ release to deploy, rather than having Sake create the release for you. The expectation + * here is that prior to deployment the code has already had all the versions/min-reqs bumped. + * @returns {boolean} + */ +export function withoutCodeChanges() +{ + return process.argv.includes('--without-code-changes'); +} + +export const skipLinting = () => { + return process.argv.includes('--skip-linting'); +} + +export const newPluginVersion = () => { + const argv = minimist(process.argv.slice(2)) + + return argv['new-version'] || null; +} diff --git a/lib/sake.js b/lib/sake.js index bf774b9..fa1c353 100644 --- a/lib/sake.js +++ b/lib/sake.js @@ -537,7 +537,7 @@ function initializeSake(config, options) { } exports.getPrereleasesPath = function () { - return this.resolvePath(process.env.SAKE_PRE_RELEASE_PATH) + return process.env.SAKE_PRE_RELEASE_PATH ? this.resolvePath(process.env.SAKE_PRE_RELEASE_PATH) : null } exports.normalizePath = function (p) { @@ -579,6 +579,7 @@ function initializeSake(config, options) { * Throws an error without the stack trace in gulp. */ exports.throwError = (message) => { + process.exitCode = 1; let err = new Error(chalk.red(message)) err.showStack = false throw err @@ -591,6 +592,7 @@ function initializeSake(config, options) { * see https://stackoverflow.com/a/30741722 */ exports.throwDeferredError = (message) => { + process.exitCode = 1; setTimeout(() => { exports.throwError(message) }) diff --git a/tasks/clean.js b/tasks/clean.js index 8988120..a503cf9 100644 --- a/tasks/clean.js +++ b/tasks/clean.js @@ -1,6 +1,7 @@ import path from 'node:path'; import del from 'del'; import sake from '../lib/sake.js' +import log from 'fancy-log'; /** * Clean dev directory from map files @@ -48,6 +49,11 @@ cleanWcRepoTask.displayName = 'clean:wc_repo' * Delete prerelease */ const cleanPrereleaseTask = (done) => { + if (sake.getPrereleasesPath() === null) { + log.info('No pre-release path defined -- skipping clean') + return done() + } + return del([ sake.getPrereleasesPath() + sake.config.plugin.id + '*.zip', sake.getPrereleasesPath() + sake.config.plugin.id + '*.txt' diff --git a/tasks/compile.js b/tasks/compile.js index 1fb1de0..2214466 100644 --- a/tasks/compile.js +++ b/tasks/compile.js @@ -18,6 +18,7 @@ import { lintPhpTask } from './lint.js' import { minifyImagesTask } from './imagemin.js' import { makepotTask } from './makepot.js' import { stylesTask } from './styles.js' +import { skipLinting } from '../helpers/arguments.js' const sass = gulpSaas(dartSaas); /************************** Scripts */ @@ -152,7 +153,12 @@ compileStyles.displayName = 'compile:styles' // Compile all plugin assets const compile = (done) => { // default compile tasks - let tasks = [lintPhpTask, 'scripts', stylesTask, minifyImagesTask] // NOTE: do not import the `scripts` constant here, otherwise it creates a circular dependency + let tasks = ['scripts', stylesTask, minifyImagesTask] // NOTE: do not import the `scripts` constant here, otherwise it creates a circular dependency + + // lint PHP unless told not to + if (! skipLinting()) { + tasks.push(lintPhpTask) + } // unless exclusively told not to, generate the POT file as well if (!sake.options.skip_pot) { diff --git a/tasks/deploy.js b/tasks/deploy.js index a7d8fd4..d519d8e 100644 --- a/tasks/deploy.js +++ b/tasks/deploy.js @@ -11,13 +11,13 @@ import replace from 'gulp-replace' import replaceTask from 'gulp-replace-task' import { promptDeployTask, promptTestedReleaseZipTask, promptWcUploadTask } from './prompt.js' import { bumpMinReqsTask, bumpTask } from './bump.js' -import { cleanPrereleaseTask, cleanWcRepoTask, cleanWpAssetsTask, cleanWpTrunkTask } from './clean.js' +import { cleanBuildTask, cleanPrereleaseTask, cleanWcRepoTask, cleanWpAssetsTask, cleanWpTrunkTask } from './clean.js' import { buildTask } from './build.js' import { gitHubCreateDocsIssueTask, gitHubCreateReleaseTask, gitHubGetReleaseIssueTask, - gitHubGetWcIssuesTask + gitHubGetWcIssuesTask, gitHubUploadZipToReleaseTask } from './github.js' import { shellGitEnsureCleanWorkingCopyTask, @@ -32,7 +32,14 @@ import { import { zipTask } from './zip.js' import { validateReadmeHeadersTask } from './validate.js' import { lintScriptsTask, lintStylesTask } from './lint.js' -import { copyWcRepoTask, copyWpAssetsTask, copyWpTagTask, copyWpTrunkTask } from './copy.js' +import { copyBuildTask, copyWcRepoTask, copyWpAssetsTask, copyWpTagTask, copyWpTrunkTask } from './copy.js' +import { + gitReleaseTag, + hasGitRelease, + isDryRunDeploy, + isNonInteractive, newPluginVersion, + withoutCodeChanges +} from '../helpers/arguments.js' let validatedEnvVariables = false @@ -41,10 +48,10 @@ let validatedEnvVariables = false function validateEnvVariables () { if (validatedEnvVariables) return - let variables = ['GITHUB_API_KEY', 'GITHUB_USERNAME', 'SAKE_PRE_RELEASE_PATH'] + let variables = ['GITHUB_API_KEY'] if (sake.config.deploy.type === 'wc') { - variables = variables.concat(['WC_CONSUMER_KEY', 'WC_CONSUMER_SECRET']) + variables = variables.concat(['WC_USERNAME', 'WC_APPLICATION_PASSWORD']) } if (sake.config.deploy.type === 'wp') { @@ -54,13 +61,62 @@ function validateEnvVariables () { sake.validateEnvironmentVariables(variables) } +/** + * Deploys the plugin, using a specific Git release + * This differs from {@see deployTask()} in that this task does NOT do any code changes to your working copy. + * It simply bundles up your code as-is, zips it, uploads it to the release you provided, and deploys it. + * It's expected that the plugin is already "fully built" at the time you run this. + */ +const deployFromReleaseTask = (done) => { + validateEnvVariables() + + if (! gitReleaseTag()) { + sake.throwError('Missing required GitHub release tag') + } + + // indicate that we are deploying + sake.options.deploy = true + + let tasks = [ + // clean the build directory + cleanBuildTask, + // copy plugin files to the build directory + copyBuildTask, + // create the zip, which will be attached to the releases + zipTask, + // upload the zip to the release, + gitHubUploadZipToReleaseTask + ] + + if (isDryRunDeploy()) { + tasks.push(function(cb) { + log.info('Dry run deployment successful') + + return cb() + }) + } else { + if (sake.config.deploy.wooId && sake.config.deploy.type === 'wc') { + tasks.push(promptWcUploadTask) + } + + if (sake.config.deploy.type === 'wp') { + tasks.push(deployToWpRepoTask) + } + } + + return gulp.series(tasks)(done) +} +deployFromReleaseTask.displayName = 'deploy:git-release' + /** * Deploy the plugin */ const deployTask = (done) => { validateEnvVariables() - if (!sake.isDeployable()) { + // we only validate if a release hasn't been provided to us + // if we are provided a release then we have to assume version numbers, etc. have already been adjusted + if (! hasGitRelease() && ! withoutCodeChanges() && !sake.isDeployable()) { sake.throwError('Plugin is not deployable: \n * ' + sake.getChangelogErrors().join('\n * ')) } @@ -76,10 +132,15 @@ const deployTask = (done) => { // ensure version is bumped bumpTask, // fetch the latest WP/WC versions & bump the "tested up to" values - fetchLatestWpWcVersionsTask, - bumpMinReqsTask, + fetchAndBumpLatestWpWcVersions, // prompt for the version to deploy as - promptDeployTask, + function (cb) { + if (! isNonInteractive()) { + return promptDeployTask() + } else { + return cb() + } + }, function (cb) { if (sake.options.version === 'skip') { log.error(chalk.red('Deploy skipped!')) @@ -88,7 +149,13 @@ const deployTask = (done) => { cb() }, // replace version number & date - replaceVersionTask, + function (cb) { + if (withoutCodeChanges()) { + return cb() + } + + return replaceVersionTask() + }, // delete prerelease, if any cleanPrereleaseTask, // build the plugin - compiles and copies to build dir @@ -103,19 +170,33 @@ const deployTask = (done) => { cb() }, // git commit & push - shellGitPushUpdateTask, + function (cb) { + if (withoutCodeChanges() || isDryRunDeploy()) { + return cb() + } + + return shellGitPushUpdateTask() + }, // create the zip, which will be attached to the releases zipTask, - // create releases, attaching the zip - deployCreateReleasesTask, + // create the release if it doesn't already exist, and attach the zip + deployCreateReleasesTask ] - if (sake.config.deploy.wooId && sake.config.deploy.type === 'wc') { - tasks.push(promptWcUploadTask) - } + if (isDryRunDeploy()) { + tasks.push(function(cb) { + log.info('Dry run deployment successful') - if (sake.config.deploy.type === 'wp') { - tasks.push(deployToWpRepoTask) + return cb() + }) + } else { + if (sake.config.deploy.wooId && sake.config.deploy.type === 'wc') { + tasks.push(promptWcUploadTask) + } + + if (sake.config.deploy.type === 'wp') { + tasks.push(deployToWpRepoTask) + } } // finally, create a docs issue, if necessary @@ -179,13 +260,15 @@ searchWtUpdateKeyTask.displayName = 'search:wt_update_key' * Internal task for replacing the version and date when deploying */ const replaceVersionTask = (done) => { - if (!sake.getVersionBump()) { + const bumpVersion = newPluginVersion() || sake.getVersionBump() + + if (!bumpVersion) { sake.throwError('No version replacement specified') } const versions = sake.getPrereleaseVersions(sake.getPluginVersion()) const versionReplacements = versions.map(version => { - return { match: version, replacement: () => sake.getVersionBump() } + return { match: version, replacement: () => bumpVersion } }) const filterChangelog = filter('**/{readme.md,readme.txt,changelog.txt}', { restore: true }) @@ -270,10 +353,19 @@ const deployCreateReleasesTask = (done) => { sake.options.repo = sake.config.deploy.dev.name sake.options.prefix_release_tag = sake.config.multiPluginRepo cb() - }, - gitHubCreateReleaseTask + } ] + if (hasGitRelease()) { + tasks.push(gitHubUploadZipToReleaseTask) + } else if (! isDryRunDeploy()) { + tasks.push(gitHubCreateReleaseTask) + } else { + // if it wasn't a dry run we would have created a release + log.info('Dry run - skipping creation of release') + return done() + } + return gulp.series(tasks)(done) } deployCreateReleasesTask.displayName = 'deploy_create_releases' @@ -442,7 +534,11 @@ const fetchLatestWpWcVersionsTask = (done) => { } fetchLatestWpWcVersionsTask.displayName = 'fetch_latest_wp_wc_versions' +const fetchAndBumpLatestWpWcVersions = gulp.series(fetchLatestWpWcVersionsTask, bumpMinReqsTask) +fetchAndBumpLatestWpWcVersions.displayName = 'fetch_and_bump_latest_wp_wc_versions' + export { + deployFromReleaseTask, deployTask, deployPreflightTask, deployValidateFrameworkVersionTask, @@ -456,5 +552,6 @@ export { updateWcRepoTask, deployToWpRepoTask, copyToWpRepoTask, - fetchLatestWpWcVersionsTask + fetchLatestWpWcVersionsTask, + fetchAndBumpLatestWpWcVersions } diff --git a/tasks/github.js b/tasks/github.js index ca6e7a5..fd82bf1 100644 --- a/tasks/github.js +++ b/tasks/github.js @@ -9,6 +9,8 @@ import dateFormat from 'dateformat' import log from 'fancy-log' import sake from '../lib/sake.js' import gulp from 'gulp' +import minimist from 'minimist'; +import { gitReleaseTag, gitReleaseUploadUrl, isNonInteractive } from '../helpers/arguments.js' let githubInstances = {} @@ -131,6 +133,10 @@ gitHubGetWcIssuesTask.displayName = 'github:get_wc_issues' * Creates a docs issue for the plugin */ const gitHubCreateDocsIssueTask = (done) => { + if (isNonInteractive()) { + return done() + } + if (! sake.config.deploy.docs) { log.warn(chalk.yellow('No docs repo configured for ' + sake.getPluginName() + ', skipping')) return done() @@ -195,8 +201,8 @@ const gitHubCreateReleaseTask = (done) => { let github = getGithub(sake.options.owner === sake.config.deploy.production.owner ? 'production' : 'dev') let version = sake.getPluginVersion() - let zipName = `${sake.config.plugin.id}.${version}.zip` - let zipPath = path.join(process.cwd(), sake.config.paths.build, zipName) + const {zipName, zipPath} = getZipNameAndPath() + let tasks = [] // prepare a zip if it doesn't already exist @@ -218,17 +224,9 @@ const gitHubCreateReleaseTask = (done) => { sake.options.release_url = result.data.html_url - github.repos.uploadReleaseAsset({ - url: result.data.upload_url, - name: zipName, - data: fs.readFileSync(zipPath), - headers: { - 'content-type': 'application/zip', - 'content-length': fs.statSync(zipPath).size - } - }).then(() => { - log('Plugin zip uploaded') - cb() + uploadZipToRelease(zipPath, zipName, result.data.upload_url).then(() => { + log('Plugin zip uploaded') + cb() }).catch((err) => { sake.throwError('Uploading release ZIP failed: ' + err.toString()) }) @@ -241,6 +239,96 @@ const gitHubCreateReleaseTask = (done) => { } gitHubCreateReleaseTask.displayName = 'github:create_release' +const gitHubUploadZipToReleaseTask = (done) => { + if (! gitReleaseTag()) { + sake.throwError('No --release-tag provided.') + } + + // why do we have to do this? lol + sake.options.owner = sake.config.deploy.dev.owner + sake.options.repo = sake.config.deploy.dev.name + + getGitHubReleaseFromTagName(gitReleaseTag()) + .then(response => { + const releaseUploadUrl = response.data.upload_url + + const {zipName, zipPath} = getZipNameAndPath() + + log(`Uploading zip ${zipName} to release ${releaseUploadUrl}`) + + let tasks = [] + + // prepare a zip if it doesn't already exist + if (! fs.existsSync(zipPath)) { + tasks.push(sake.options.deploy ? 'compress' : 'zip') + } + + tasks.push(function (cb) { + uploadZipToRelease(zipPath, zipName, releaseUploadUrl).then(() => { + log('Plugin zip uploaded') + cb() + }).catch((err) => { + sake.throwError('Uploading release ZIP failed: ' + err.toString()) + }) + }) + + gulp.series(tasks)(done) + }) + .catch((err) => { + sake.throwError('Failed to upload zip to release: '.err.toString()) + }) +} +gitHubUploadZipToReleaseTask.displayName = 'github:upload_zip_to_release' + +/** + * + * @param {string} tagName + * @returns {Promise} + */ +function getGitHubReleaseFromTagName(tagName) +{ + let github = getGithub(sake.options.owner === sake.config.deploy.production.owner ? 'production' : 'dev') + const owner = sake.options.owner + const repo = sake.options.repo || sake.config.plugin.id + + if (! owner || ! repo) { + sake.throwError(chalk.yellow('The owner or the slug of the repo for ' + sake.getPluginName() + ' are missing.')) + } + + return github.request('GET /repos/{owner}/{repo}/releases/tags/{tag}', { + owner: owner, + repo: repo, + tag: tagName + }) +} + +function getZipNameAndPath() +{ + let version = sake.getPluginVersion() + let zipName = `${sake.config.plugin.id}.${version}.zip` + let zipPath = path.join(process.cwd(), sake.config.paths.build, zipName) + + return { + zipName: zipName, + zipPath: zipPath + } +} + +function uploadZipToRelease(zipPath, zipName, releaseUrl) +{ + let github = getGithub(sake.options.owner === sake.config.deploy.production.owner ? 'production' : 'dev') + + return github.repos.uploadReleaseAsset({ + url: releaseUrl, + name: zipName, + data: fs.readFileSync(zipPath), + headers: { + 'content-type': 'application/zip', + 'content-length': fs.statSync(zipPath).size + } + }) +} + /** * Create release milestones for each Tuesday */ @@ -337,6 +425,7 @@ export { gitHubGetWcIssuesTask, gitHubCreateDocsIssueTask, gitHubCreateReleaseTask, + gitHubUploadZipToReleaseTask, gitHubCreateReleaseMilestonesTask, gitHubCreateMonthMilestonesTask } diff --git a/tasks/prompt.js b/tasks/prompt.js index 94f4392..fef571a 100644 --- a/tasks/prompt.js +++ b/tasks/prompt.js @@ -6,6 +6,7 @@ import _ from 'lodash' import sake from '../lib/sake.js' import gulp from 'gulp' import { wcDeployTask } from './wc.js' +import { isNonInteractive } from '../helpers/arguments.js' function filterIncrement (value) { if (value[1] === 'custom') { @@ -102,13 +103,18 @@ promptDeployTask.displayName = 'prompt:deploy' * Internal task for prompting whether to upload the plugin to WooCommerce */ const promptWcUploadTask = (done) => { + const uploadSeries = gulp.series(wcDeployTask) + if (isNonInteractive()) { + return uploadSeries(done) + } + inquirer.prompt([{ type: 'confirm', name: 'upload_to_wc', message: 'Upload plugin to WooCommerce.com?' }]).then((answers) => { if (answers.upload_to_wc) { - gulp.series(wcDeployTask)(done) + uploadSeries(done) } else { log.error(chalk.red('Skipped uploading to WooCommerce.com')) done() @@ -121,6 +127,10 @@ promptWcUploadTask.displayName = 'prompt:wc_upload' * Internal task for prompting whether the release has been tested */ const promptTestedReleaseZipTask = (done) => { + if (isNonInteractive()) { + return done() + } + inquirer.prompt([{ type: 'confirm', name: 'tested_release_zip', diff --git a/tasks/scripts.js b/tasks/scripts.js index dab4199..7e8ad2e 100644 --- a/tasks/scripts.js +++ b/tasks/scripts.js @@ -2,6 +2,7 @@ import gulp from 'gulp' import { lintCoffeeTask, lintJsTask, lintScriptsTask } from './lint.js' import { compileBlocksTask, compileCoffeeTask, compileJsTask, compileScripts } from './compile.js' import sake from '../lib/sake.js' +import { skipLinting } from '../helpers/arguments.js' /** * The main task @@ -9,8 +10,8 @@ import sake from '../lib/sake.js' const scriptsTask = (done) => { let tasks = [lintScriptsTask, compileScripts] - // don't lint styles if they have already been linted, unless we're watching - if (! sake.isWatching && gulp.lastRun(lintScriptsTask)) { + // don't lint scripts if they have already been linted, unless we're watching + if ((! sake.isWatching && gulp.lastRun(lintScriptsTask)) || skipLinting()) { tasks.shift() } diff --git a/tasks/styles.js b/tasks/styles.js index 14a3af3..7206dda 100644 --- a/tasks/styles.js +++ b/tasks/styles.js @@ -2,12 +2,13 @@ import gulp from 'gulp' import sake from '../lib/sake.js' import { lintStylesTask } from './lint.js' import { compileStyles } from './compile.js' +import { skipLinting } from '../helpers/arguments.js' const stylesTask = (done) => { let tasks = [lintStylesTask, compileStyles] // don't lint styles if they have already been linted, unless we're watching - if (!sake.isWatching && gulp.lastRun(lintStylesTask)) { + if ((!sake.isWatching && gulp.lastRun(lintStylesTask)) || skipLinting()) { tasks.shift() }