ci: implement a nightlies GitHub Actions workflow

Implement a GitHub Actions workflow in `.github/workflows/nightlies.yml` named
*Nightlies*, which is scheduled to run once daily at 00:00 UTC.

At present the workflow includes one job named *release*, which is responsible
for publishing prerelease GitHub releases and NPM packages. Each prerelease
created (per package) will have a `nightly` [semver identifier][preid], and
each successive nightly release will be paired with the `nightly`
[dist-tag][dist-tag] on the NPM registry (per package).

During the release job, actions taken in this GitHub repository (commits, tags,
releases) and on the NPM registry (package publication) will be performed using
credentials associated with the following accounts:

* https://github.com/embarkbot
* https://www.npmjs.com/~embarkbot

For that purpose, corresponding [secrets][secrets] (link requires admin access)
were created in this repository consisting of API tokens generated for the
@embarkbot GitHub and NPM accounts. Logins for the @embarkbot accounts
themselves are protected by 2FA.

Implement `scripts/nightly-release.js` (`npm run release:nightly`), which is
responsible for running `lerna publish` in the GitHub Actions workflow. Also
implement `scripts/stable-release.js` (`npm run release:stable`), which is
intended to be run locally by someone on the Embark Team. Both scripts borrow
heavily from the existing `scripts/release.js`, and the process of authoring
and experimenting with them influenced refactors to the latter.

Use a `--force-publish` major-release strategy to prevent major-version drift
between packages in the monorepo. How it works: when the stable-release script
is run (`npm run release:stable`), if the current prerelease version involves a
major version increase relative to the most recent stable release then **all**
packages are bumped to the new major stable version. Otherwise, only the
packages currently in prerelease are graduated to the new minor/patch stable
version. In either case, the `nightly` dist-tag of each package published is
updated to resolve to the new stable version.

The reason for adopting this strategy *(a decision which can be revisited and
changed any time in the future)* is based on a concern that downstream users
would have a confusing developer UX if across `embark-*` packages there are
differing major versions.

To understand how the major-version drift would happen, consider the following
hypothetical scenario where `--force-publish` *isn't* used in stable releases
and `nightly` dist-tags aren't updated to resolve to the latest stable version:
assume the current stable version is `6.5.4`. A breaking change lands for
`embark-core`. The next nightly release bumps `embark-core` and about 40 other
packages to `7.0.0-nightly.0`. However, `embark-utils` (and others) isn't
bumped because it doesn't depend on `embark-core`. Later, without any
intervening changes to `embark-utils`, the prerelease is graduated so that
`embark-core`, etc. bump to `7.0.0`. So then some `embark-*` packages are at
major version `7` while others are still at `6`. *Note* that this is the case
even though this monorepo uses Lerna's *"fixed"* versioning mode. Inside the
monorepo, `lerna` makes sure that everything is okay, i.e. with respect to
automatically updating dependents' version specifiers for their dependencies
that are within the monorepo. But for downstream users things are a bit more
complex. If someone wanted to use `embark-utils` on its own and specified
`^7.0.0` as the version range (after observing that `embark` itself is in a
`7.x` series) it won't work because `embark-utils` is still in `6.x`. In the
general case, users may have to manually cross-check major versions of various
`embark-*` packages that they specify in their projects' `package.json`
files. There are tools like [npm-check-updates][ncu] that can make the task
easier, but there is still likely to be some confusion, especially given the
large and growing number of packages in this monorepo. Another area of
confusion would exist around the `nightly` dist-tag. In the scenario above,
`embark-core@nightly` (and/or `@nightly` of its dependents, e.g. `embark`)
would resolve to `7.0.0-nightly.0` but `embark-utils@nightly` would resolve to
some `6.5.4-nightly.N` (💣), i.e. a prerelease version that predates the
current stable `6.5.4` release of `embark-utils` (and *might* not include all
changes that landed in `embark-utils` prior to that stable release).

By bumping all packages each time there is a major stable release, and by
having the `nightly` dist-tag always point to a package's most recent
release (whether stable or prerelease), the problems described above are
avoided.

To see the `--force-publish` major-release strategy in action take a look at
the [commit history][history] for the
[nightly-release-workflow-tester][mb-nrwt] repo together with the *Versions*
tab of the NPM pages for the [foo][foo], [bar][bar], [baz][baz], and
[quux][quux] packages. Ignore the version history for `<= 2.0.1` because those
pre/releases were made with a different strategy than the current one.

Refactor the existing `scripts/release.js` to make it more flexible generally
and with respect to options that can be forwarded to `lerna`. In particular,
it's now possible to run `lerna version` instead of `lerna publish` (the
default behavior) by using the `--version-only` cli option; when combining that
usage with `--skip-qa` and `--no-push` it's possible to conveniently and
quickly experiment with the [`bump` positional][bump] and additional options
such as `--force-publish`, `--conventional-prerelease`, and
`--conventional-graduate`, i.e. to better understand how `lerna` will update
package versions. That ability should make it much simpler to figure out the
best course of action to take locally (manually) when a nightly release
completely or partially failed (which could happen for a number of reasons), as
well for other scenarios such as making a minor/patch release in a previous
line of major releases, or when making two/more successive stable releases
without a nightly release having happened in the meantime.

An important change to `scripts/release.js` is that by default it makes use of
the `--create-release github` option for `lerna version|publish`. For that to
work, an environment variable named `GH_TOKEN` must be defined with a properly
[scoped][scopes] GitHub [personal access token][pa-token] (`public_repo` scope
is sufficient for creating releases). The same is true for
`scripts/stable-release.js`.

Delete the `.github/PULL_REQUEST_TEMPLATE` directory and the templates it
contained. Unlike for GitHub issue creation, there is no prompt-page for
picking from a repo's PR templates; to use a PR template a `template=[name]`
[query parameter][template-query] must be appended to the URL of the PR
creation page. So the PR templates ended up unused by the Embark Team and
external contributors because it's not convenient to use them. Restore the
default PR template we had in place some time ago (with some small revisions)
since it seems like a helpful starting point, especially for external
contributors. Consistently use all-lowercase filenames for ISSUE/PR templates.

[preid]: https://semver.org/#spec-item-9
[dist-tag]: https://docs.npmjs.com/cli/dist-tag
[secrets]: https://github.com/embarklabs/embark/settings/secrets
[ncu]: https://www.npmjs.com/package/npm-check-updates
[history]: https://github.com/michaelsbradleyjr/nightly-release-workflow-tester/commits/master
[mb-nrwt]: https://github.com/michaelsbradleyjr/nightly-release-workflow-tester/
[foo]: https://www.npmjs.com/package/nightly-release-workflow-tester-foo?activeTab=versions
[bar]: https://www.npmjs.com/package/nightly-release-workflow-tester-bar?activeTab=versions
[baz]: https://www.npmjs.com/package/nightly-release-workflow-tester-baz?activeTab=versions
[quux]: https://www.npmjs.com/package/nightly-release-workflow-tester-quux?activeTab=versions
[bump]: https://github.com/lerna/lerna/tree/master/commands/version#semver-bump
[scopes]: https://developer.github.com/apps/building-oauth-apps/understanding-scopes-for-oauth-apps/
[pa-token]: https://help.github.com/en/github/authenticating-to-github/creating-a-personal-access-token-for-the-command-line
[template-query]: https://help.github.com/en/github/building-a-strong-community/creating-a-pull-request-template-for-your-repository
This commit is contained in:
Michael Bradley, Jr 2020-01-07 11:09:38 -06:00 committed by Michael Bradley
parent 82b12fd470
commit 4c7fc6d8cc
11 changed files with 858 additions and 132 deletions

View File

@ -1,20 +0,0 @@
---
name: ⚙ Bugfix
about: Fixed a bug? 🐞
---
### What bug have you fixed?
<!-- Fill in the relevant information below to help triage your issue. -->
#### How did you fix it (give a brief summary)?
<!-- Provide a summary of the improvement you are submitting. -->
### Questions
<If relevant, write a list of questions that you would like to discuss related to the changes that you have made.>
### Review
<use @mentions for quick questions, specific feedback, and progress updates.>
### Cool Spaceship Picture

View File

@ -1,20 +0,0 @@
---
name: ⚙ Feature
about: Implemented a new feature? 🎁
---
### What feature did you implement?
<!-- Fill in the relevant information below to help triage your issue. -->
#### Anything that needs special attention (breaking changes etc)?
<!-- Provide a summary of the improvement you are submitting. -->
### Questions
<If relevant, write a list of questions that you would like to discuss related to the changes that you have made.>
### Review
<use @mentions for quick questions, specific feedback, and progress updates.>
### Cool Spaceship Picture

23
.github/pull_request_template.md vendored Normal file
View File

@ -0,0 +1,23 @@
### What did you refactor, implement, or fix?
<!-- Fill in the relevant information below to help us evaluate your proposed changes. -->
#### How did you do it?
<!-- Provide a summary of the improvement/s you are submitting. -->
#### Is there anything that needs special attention (breaking changes, etc)?
<!-- Explain any special considerations. -->
### Questions
<!-- If relevant, write a list of questions that you would like to discuss related to your changes. -->
### Review
<!-- Use @mentions for quick questions, specific feedback, and progress updates. -->
### Cool Spaceship Picture
<!-- Star Trek/Wars, n/BSG, Voltron, Transformers... all welcome, but not all equally cool ;-) ->

80
.github/workflows/nightlies.yml vendored Normal file
View File

@ -0,0 +1,80 @@
name: Nightlies
on:
# This workflow is triggered once daily at midnight (UTC)
# See: https://crontab.guru/#0_0_*_*_*
schedule:
- cron: '0 0 * * *'
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v2
# Credentials for pushing commits and tags are persisted by actions/checkout@v2 by default
# See: https://github.com/actions/checkout#usage
with:
ref: master
# Fetch full history for the master branch
# See: https://github.com/actions/checkout#usage
fetch-depth: 0
# EMBARKBOT_GITHUB_TOKEN should correspond to https://github.com/embarkbot
token: ${{ secrets.EMBARKBOT_GITHUB_TOKEN }}
- name: Fetch all tags
# See: https://github.com/actions/checkout#fetch-all-tags
run: |
git fetch --depth=1 origin +refs/tags/*:refs/tags/*
- name: Setup Node.js
uses: actions/setup-node@v1
with:
node-version: '^10.17.0'
- name: Update npm
run: |
npm i -g npm
- name: Update yarn
run: |
curl -o- -L https://yarnpkg.com/install.sh | bash -s -- --version $(npm show yarn@${YARN_VERSION} version --json | npx json -- -1 2>/dev/null)
env:
YARN_VERSION: '^1.19.1'
- name: Install dependencies
run: |
export PATH="${HOME}/.yarn/bin:${HOME}/.config/yarn/global/node_modules/.bin:${PATH}"
echo Node.js is $(node --version)
echo npm is v$(npm --version)
echo yarn is v$(yarn --version); echo
yarn install
npm run cwtree
- name: Build
# Run typecheck in addition to build since both tsc and babel generate files into packages' dist/ directories
run: |
export PATH="${HOME}/.yarn/bin:${HOME}/.config/yarn/global/node_modules/.bin:${PATH}"
echo Node.js is $(node --version)
echo npm is v$(npm --version)
echo yarn is v$(yarn --version); echo
npm run typecheck
npm run build
npm run cwtree
- name: Release
run: |
export PATH="${HOME}/.yarn/bin:${HOME}/.config/yarn/global/node_modules/.bin:${PATH}"
echo Node.js is $(node --version)
echo npm is v$(npm --version)
echo yarn is v$(yarn --version); echo
git config user.name 'EmbarkBot'
git config user.email '<59620708+embarkbot@users.noreply.github.com>'
npm config set //registry.npmjs.org/:_authToken=${NPM_TOKEN}
npm whoami 1>/dev/null
echo Logged into NPM as user $(npm whoami); echo
npm run release:nightly
npm run cwtree
env:
# GH_TOKEN is the env variable referenced by `lerna version|publish` for authenticating via GitHub token in order to create a release
# See: https://github.com/lerna/lerna/tree/master/commands/version#--create-release-type
# EMBARKBOT_GITHUB_TOKEN should correspond to https://github.com/embarkbot
GH_TOKEN: ${{ secrets.EMBARKBOT_GITHUB_TOKEN }}
IS_GITHUB_ACTIONS_WORKFLOW: 't'
# EMBARKBOT_NPM_TOKEN should correspond to https://www.npmjs.com/~embarkbot
NPM_TOKEN: ${{ secrets.EMBARKBOT_NPM_TOKEN }}
- name: Remove NPM credentials
# Delete .npmrc instead of running `npm logout` because the logout command permanently invalidates the current token
run: |
rm ${HOME}/.npmrc

View File

@ -12,7 +12,8 @@
"minimist": "1.2.0",
"npm-run-all": "4.1.5",
"nyc": "13.1.0",
"rimraf": "3.0.0"
"rimraf": "3.0.0",
"semver": "5.6.0"
},
"engines": {
"node": ">=10.17.0 <12.0.0",
@ -52,6 +53,8 @@
"reboot": "npm run clean",
"reboot:full": "npm run clean:full",
"release": "node scripts/release",
"release:nightly": "node scripts/nightly-release",
"release:stable": "node scripts/stable-release",
"reset": "node scripts/monorun --stream reset && npm-run-all reset:*",
"reset:top": "npx rimraf .tsconfig.collective.json .nyc_output coverage",
"reset:tsbuildinfo": "npx lerna exec --parallel -- npx rimraf node_modules/.cache/tsc",

186
scripts/nightly-release.js Normal file
View File

@ -0,0 +1,186 @@
/* global __dirname process require */
const chalk = require('chalk');
const {execSync} = require('child_process');
const path = require('path');
const {readJsonSync} = require('fs-extra');
const branch = `master`;
const commitMsg = `chore(prerelease): %v`;
const distTag = `nightly`;
const preId = `nightly`;
const remote = `origin`;
const cyan = (str) => chalk.cyan(str);
const execSyncInherit = (cmd) => execSync(cmd, {stdio: 'inherit'});
const log = (mark, str, which = 'log') => console[which](
mark, str.filter(s => !!s).join(` `)
);
const logError = (...str) => log(chalk.red(``), str, 'error');
const logInfo = (...str) => log(chalk.blue(``), str);
const logSuccess = (...str) => log(chalk.green(``), str);
const logWarning = (...str) => log(chalk.yellow('‼︎'), str);
const failMsg = `${chalk.red(`NIGHTLY RELEASE FAILED!`)} Stopping right here.`;
const runCommand = (cmd, inherit = true, display) => {
logInfo(`Running command ${cyan(display || cmd)}.`);
let out;
if (inherit) {
execSyncInherit(cmd);
} else {
out = execSync(cmd);
}
return out;
};
(async () => {
if (!process.env.GH_TOKEN) {
logError(
`Environment variable ${cyan('GH_TOKEN')} was not defined or falsy. It`,
`must be defined with a properly scoped GitHub personal access token.`
);
logError(failMsg);
process.exit(1);
}
if (!process.env.IS_GITHUB_ACTIONS_WORKFLOW) {
logError(
`This script should only be run in the context of a GitHub Actions`,
`workflow (that workflow should set the environment variable`,
`${cyan('IS_GITHUB_ACTIONS_WORKFLOW')} to a truthy value).`
);
logError(failMsg);
process.exit(1);
}
const lernaJsonPath = path.join(__dirname, '../lerna.json');
logInfo(`Reading ${cyan(lernaJsonPath)}...`);
let lernaJson, registry;
try {
lernaJson = readJsonSync(lernaJsonPath);
registry = lernaJson.command.publish.registry;
if (!registry) throw new Error('missing registry in lerna.json');
} catch (e) {
console.error(e.stack || e);
logError(
`Could not read values from ${cyan(lernaJsonPath)}. Please check the`,
`error above.`
);
logError(failMsg);
process.exit(1);
}
logInfo(`Determining the current branch...`);
let currentBranch;
try {
currentBranch = runCommand(`git rev-parse --abbrev-ref HEAD`, false)
.toString()
.trim();
} catch (e) {
logError(`Could not determine the branch. Please check the error above.`);
logError(failMsg);
process.exit(1);
}
if (currentBranch === branch) {
logSuccess(`Current branch and release branch are the same.`);
} else {
logError(
`Current branch ${cyan(currentBranch)} is not the same as release branch`,
`${cyan(branch)}.`
);
logError(failMsg);
process.exit(1);
}
logInfo(
`Fetching commits from ${cyan(remote)} to compare local and remote`,
`branches...`
);
try {
runCommand(`git fetch ${remote}`, false);
} catch (e) {
logError(`Could not fetch latest commits. Please check the error above.`);
logError(failMsg);
process.exit(1);
}
let localRef, remoteRef;
try {
localRef = runCommand(`git rev-parse ${branch}`, false).toString().trim();
remoteRef = runCommand(`git rev-parse ${remote}/${branch}`, false)
.toString()
.trim();
} catch (e) {
logError(`A problem occured. Please check the error above.`);
logError(failMsg);
process.exit(1);
}
if (localRef === remoteRef) {
logSuccess(`Local branch is in sync with remote branch.`);
} else {
logError(
`Local branch ${cyan(branch)} is not in sync with`,
`${cyan(`${remote}/${branch}`)}.`
);
logError(failMsg);
process.exit(1);
}
const lernaPublish = [
`lerna`,
`publish`,
`--conventional-commits`,
`--conventional-prerelease`,
`--create-release github`,
`--dist-tag ${distTag}`,
`--git-remote ${remote}`,
`--message "${commitMsg}"`,
`--preid ${preId}`,
`--registry ${registry}`,
`--yes`
].filter(str => !!str).join(` `);
try {
runCommand(lernaPublish);
if (localRef ===
runCommand(`git rev-parse ${branch}`, false).toString().trim()) {
logWarning(
chalk.yellow(`NIGHTLY RELEASE STOPPED!`),
`No commit or tag was created. No packages were published. This is`,
`most likely due to no qualifying changes having been made to the`,
`${cyan(branch)} branch since the previous release. Please check the`,
`output above.`
);
process.exit(0);
}
} catch (e) {
logError(`A problem occured. Please check the error above.`);
const lernaDocsUrl = 'https://github.com/lerna/lerna/tree/master/commands/publish#bump-from-package';
logError(
failMsg,
`Make sure to clean up commits, tags, and releases as necessary. Check`,
`the package registry to verify if any packages were published. If so`,
`they may need to be flagged as deprecated since the`,
`${cyan('lerna publish')} command exited with error. In some cases it`,
`may be possible to salvage an imcomplete release by using the`,
`${cyan('from-package')} keyword with the ${cyan('lerna publish')}`,
`command. See: ${lernaDocsUrl}`
);
process.exit(1);
}
logSuccess(`${chalk.green(`NIGHTLY RELEASE SUCCEEDED!`)} Woohoo! Done.`);
})().then(() => {
process.exit(0);
}).catch(e => {
console.error(e.stack || e);
logError(`A problem occured. Please check the error above.`);
logError(failMsg);
process.exit(1);
});

View File

@ -4,7 +4,7 @@ const chalk = require('chalk');
const {execSync} = require('child_process');
const minimist = require('minimist');
const path = require('path');
const {prompt} = require('promptly');
const {readJsonSync} = require('fs-extra');
const args = minimist(process.argv.slice(2));
@ -14,12 +14,60 @@ const bump = args._[0] || DEFAULT_BUMP;
const DEFAULT_COMMIT_MSG = `chore(release): %v`;
const commitMsg = args['commit-message'] || DEFAULT_COMMIT_MSG;
const DEFAULT_CONVENTIONAL_COMMITS = true;
let cCommits;
// with --no-conventional-commits cli option `args['conventional-commits']` will be `false`
// there is never a need to use --conventional-commits cli option because the
// default behavior is `true`
if (args['conventional-commits'] === false) {
cCommits = false;
} else {
cCommits = DEFAULT_CONVENTIONAL_COMMITS;
}
const DEFAULT_CONVENTIONAL_GRADUATE = false;
const cGraduate = args['conventional-graduate'] || DEFAULT_CONVENTIONAL_GRADUATE;
const DEFAULT_CONVENTIONAL_PRERELEASE = false;
const cPrerelease = args['conventional-prerelease'] || DEFAULT_CONVENTIONAL_PRERELEASE;
// if not using --no-create-release or --no-push cli option then an environment
// variable named GH_TOKEN must be defined with a properly scoped GitHub
// personal access token; for local releases, e.g. with verdaccio,
// --no-create-release should be used (unless using --no-push, which implies
// --no-create-release)
// See: https://github.com/lerna/lerna/tree/master/commands/version#--create-release-type
const DEFAULT_CREATE_RELEASE = true;
let createRelease;
// with --no-create-release cli option `args['create-release']` will be `false`
// there is never a need to use --create-release cli option because the default
// is `true`
if (args['create-release'] === false) {
createRelease = false;
} else {
createRelease = DEFAULT_CREATE_RELEASE;
}
const DEFAULT_DIST_TAG = `latest`;
const distTag = args['dist-tag'] || DEFAULT_DIST_TAG;
const DEFAULT_FORCE_PUBLISH = false;
const forcePublish = args['force-publish'] || DEFAULT_FORCE_PUBLISH;
const DEFAULT_GIT_REMOTE = `origin`;
const remote = args['git-remote'] || DEFAULT_GIT_REMOTE;
const DEFAULT_NO_PUSH = false;
let noPush;
// with --no-push cli option `args['push']` will be `false`
// there is never a need to use --push cli option because the default behavior
// is to push
if (args['push'] === false) {
noPush = true;
} else {
noPush = DEFAULT_NO_PUSH;
}
const DEFAULT_PRE_ID = null;
const preId = args.preid || DEFAULT_PRE_ID;
@ -29,6 +77,12 @@ const branch = args['release-branch'] || DEFAULT_RELEASE_BRANCH;
const DEFAULT_SIGN = false;
const sign = args.sign || DEFAULT_SIGN;
const DEFAULT_SKIP_QA = false;
const skipQa = args['skip-qa'] || DEFAULT_SKIP_QA;
const DEFAULT_VERSION_ONLY = false;
const versionOnly = args['version-only'] || DEFAULT_VERSION_ONLY;
const cyan = (str) => chalk.cyan(str);
const execSyncInherit = (cmd) => execSync(cmd, {stdio: 'inherit'});
const log = (mark, str, which = 'log') => console[which](
@ -57,84 +111,118 @@ const runCommand = (cmd, inherit = true, display) => {
};
(async () => {
try {
let DEFAULT_REGISTRY, registry;
const lernaJsonPath = path.join(__dirname, '../lerna.json');
try {
const lernaJson = require(lernaJsonPath);
if (!noPush && createRelease && !process.env.GH_TOKEN) {
logError(
`Environment variable ${cyan('GH_TOKEN')} was not defined or falsy. It`,
`must be defined with a properly scoped GitHub personal access token,`,
`or else the ${cyan('--no-create-release')} or ${cyan('--no-push')}`,
`option should be used. Always use ${cyan('--no-create-release')} when`,
`running a local release, e.g. with ${cyan('verdaccio')} (unless using`,
`${cyan('--no-push')}, which implies ${cyan('--no-create-release')}).`
);
logError(failMsg);
process.exit(1);
}
const lernaJsonPath = path.join(__dirname, '../lerna.json');
logInfo(`Reading ${cyan(lernaJsonPath)}...`);
let lernaJson;
try {
lernaJson = readJsonSync(lernaJsonPath);
} catch (e) {
console.error(e.stack || e);
logError(
`Could not read ${cyan(lernaJsonPath)}. Please check the error above.`
);
logError(failMsg);
process.exit(1);
}
let DEFAULT_REGISTRY, registry;
if (!versionOnly) {
try {
DEFAULT_REGISTRY = lernaJson.command.publish.registry;
if (!DEFAULT_REGISTRY) throw new Error('missing registry in lerna.json');
registry = args.registry || DEFAULT_REGISTRY;
} catch (e) {
console.error(e.stack);
console.error(e.stack || e);
logError(
`Could not read values from ${cyan(lernaJsonPath)}.`,
`Please check the error above.`
`Could not read values from ${cyan(lernaJsonPath)}. Please check the`,
`error above.`
);
throw new Error();
logError(failMsg);
process.exit(1);
}
}
logInfo(`Checking the working tree...`);
logInfo(`Checking the working tree...`);
try {
runCommand(`npm run --silent cwtree`, true, `npm run cwtree`);
logSuccess(`Working tree is clean.`);
} catch (e) {
logError(
`Working tree is dirty or has untracked files.`,
`Please make necessary changes or commits before rerunning this script.`
);
throw new Error();
}
try {
runCommand(`npm run --silent cwtree`, true, `npm run cwtree`);
logSuccess(`Working tree is clean.`);
} catch (e) {
logError(
`Working tree is dirty or has untracked files.`,
`Please make necessary changes or commits before rerunning this script.`
);
logError(failMsg);
process.exit(1);
}
reportSetting(`Release branch`, branch, DEFAULT_RELEASE_BRANCH);
logInfo(`Determining the current branch...`);
reportSetting(`Release branch`, branch, DEFAULT_RELEASE_BRANCH);
logInfo(`Determining the current branch...`);
let currentBranch;
try {
currentBranch = runCommand(`git rev-parse --abbrev-ref HEAD`, false)
.toString()
.trim();
} catch (e) {
logError(`Could not determine the branch. Please check the error above.`);
throw new Error();
}
let currentBranch;
try {
currentBranch = runCommand(`git rev-parse --abbrev-ref HEAD`, false)
.toString()
.trim();
} catch (e) {
logError(`Could not determine the branch. Please check the error above.`);
logError(failMsg);
process.exit(1);
}
if (currentBranch === branch) {
logSuccess(`Current branch and release branch are the same.`);
} else {
logError(
`Current branch ${cyan(currentBranch)} is not the same as release`,
`branch ${cyan(branch)}. Please checkout the release branch before`,
`rerunning this script or rerun with`,
`${cyan(`--release-branch ${currentBranch}`)}.`
);
throw new Error();
}
if (currentBranch === branch) {
logSuccess(`Current branch and release branch are the same.`);
} else {
logError(
`Current branch ${cyan(currentBranch)} is not the same as release`,
`branch ${cyan(branch)}. Please checkout the release branch before`,
`rerunning this script or rerun with`,
`${cyan(`--release-branch ${currentBranch}`)}.`
);
logError(failMsg);
process.exit(1);
}
let localRef;
if (!noPush) {
reportSetting(`Git remote`, remote, DEFAULT_GIT_REMOTE);
logInfo(
`Fetching commits from ${cyan(remote)}`,
`to compare local and remote branches...`
`Fetching commits from ${cyan(remote)} to compare local and remote`,
`branches...`
);
try {
runCommand(`git fetch ${remote}`, false);
} catch (e) {
logError(`Could not fetch latest commits. Please check the error above.`);
throw new Error();
logError(failMsg);
process.exit(1);
}
let localRef, remoteRef;
let remoteRef;
try {
localRef = runCommand(`git rev-parse ${branch}`, false).toString().trim();
remoteRef = (
runCommand(`git rev-parse ${remote}/${branch}`, false).toString().trim()
);
remoteRef = runCommand(`git rev-parse ${remote}/${branch}`, false)
.toString()
.trim();
} catch (e) {
logError(`A problem occured. Please check the error above.`);
throw new Error();
logError(failMsg);
process.exit(1);
}
if (localRef === remoteRef) {
@ -145,9 +233,24 @@ const runCommand = (cmd, inherit = true, display) => {
`${cyan(`${remote}/${branch}`)}.`,
`Please sync branches before rerunning this script.`
);
throw new Error();
logError(failMsg);
process.exit(1);
}
} else {
try {
localRef = runCommand(`git rev-parse ${branch}`, false).toString().trim();
} catch (e) {
logError(`A problem occured. Please check the error above.`);
logError(failMsg);
process.exit(1);
}
}
if (skipQa) {
logWarning(
`Skipping the QA suite. You already built the packages, right?`
);
} else {
logInfo(
`It's time to run the QA suite, this will take awhile...`
);
@ -157,54 +260,96 @@ const runCommand = (cmd, inherit = true, display) => {
logSuccess(`All steps succeeded in the QA suite.`);
} catch (e) {
logError(`A step failed in the QA suite. Please check the error above.`);
throw new Error();
logError(failMsg);
process.exit(1);
}
}
logInfo(`Publishing with Lerna...`);
if (bump) reportSetting(`Version bump`, bump, DEFAULT_BUMP);
if (preId) reportSetting(`Prerelease identifier`, preId, DEFAULT_PRE_ID);
reportSetting(`Package distribution tag`, distTag, DEFAULT_DIST_TAG);
reportSetting(`Commit message format`, commitMsg, DEFAULT_COMMIT_MSG);
reportSetting(`Signature option`, sign, DEFAULT_SIGN);
reportSetting(`Package registry`, registry, DEFAULT_REGISTRY);
logInfo(`${versionOnly ? 'Versioning' : 'Publishing'} with Lerna...`);
if (bump) reportSetting(`Version bump`, bump, DEFAULT_BUMP);
reportSetting(`Conventional commits option`, cCommits, DEFAULT_CONVENTIONAL_COMMITS);
reportSetting(`Conventional graduate option`, cGraduate, DEFAULT_CONVENTIONAL_GRADUATE);
reportSetting(`Conventional prerelease option`, cPrerelease, DEFAULT_CONVENTIONAL_PRERELEASE);
if (!noPush) reportSetting(`Create GitHub release option`, createRelease, DEFAULT_CREATE_RELEASE);
if (!versionOnly) reportSetting(`NPM dist-tag`, distTag, DEFAULT_DIST_TAG);
reportSetting(`Force publish option`, forcePublish, DEFAULT_FORCE_PUBLISH);
reportSetting(`Commit message format`, commitMsg, DEFAULT_COMMIT_MSG);
reportSetting(`No push option`, noPush, DEFAULT_NO_PUSH);
if (preId) reportSetting(`Prerelease identifier`, preId, DEFAULT_PRE_ID);
if(!versionOnly) reportSetting(`Package registry`, registry, DEFAULT_REGISTRY);
reportSetting(`Signature option`, sign, DEFAULT_SIGN);
const lernaPublish = [
`lerna publish`,
bump || ``,
(preId && `--preid ${preId}`) || ``,
`--dist-tag ${distTag}`,
`--conventional-commits`,
`--message "${commitMsg}"`,
(sign && `--sign-git-commit`) || ``,
(sign && `--sign-git-tag`) || ``,
`--git-remote ${remote}`,
`--registry ${registry}`
].filter(str => !!str).join(` `);
const lernaCmd = [
`lerna`,
(versionOnly && `version`) || `publish`,
bump || ``,
(cCommits && `--conventional-commits`) || ``,
(cCommits && cGraduate && `--conventional-graduate${cGraduate === true ? '' : `=${cGraduate}`}`) || ``,
(cCommits && cPrerelease && `--conventional-prerelease${cPrerelease === true ? '' : `=${cPrerelease}`}`) || ``,
(!noPush && createRelease && `--create-release github`) || ``,
(!versionOnly && `--dist-tag ${distTag}`) || ``,
(forcePublish && `--force-publish${forcePublish === true ? '' : `=${forcePublish}`}`) || ``,
(!noPush && `--git-remote ${remote}`) || ``,
`--message "${commitMsg}"`,
(noPush && `--no-push`) || ``,
(preId && `--preid ${preId}`) || ``,
(!versionOnly && `--registry ${registry}`) || ``,
(sign && `--sign-git-commit`) || ``,
(sign && `--sign-git-tag`) || ``
].filter(str => !!str).join(` `);
try {
runCommand(lernaPublish);
if (localRef ===
runCommand(`git rev-parse ${branch}`, false).toString().trim()) {
logWarning(
chalk.yellow(`RELEASE STOPPED!`),
`No commit or tag was created. No packages were published.`
);
process.exit(0);
try {
runCommand(lernaCmd);
if (bump !== 'from-package' && localRef ===
runCommand(`git rev-parse ${branch}`, false).toString().trim()) {
let action, cmd, noPubMsg;
if (versionOnly) {
action = 'version creation';
cmd = 'version';
noPubMsg = '';
} else {
action = 'publication';
cmd = 'publish';
noPubMsg = `No packages were published. `;
}
} catch (e) {
console.error();
logError(`A problem occured. Please check the error above.`);
throw new Error();
logWarning(
chalk.yellow(`RELEASE STOPPED!`),
`No commit or tag was created. ${noPubMsg}Please check the output`,
`above if you did not stop ${action} via a prompt from the`,
`${cyan(`lerna ${cmd}`)} command.`
);
process.exit(0);
}
logSuccess(`${chalk.green(`RELEASE SUCCEEDED!`)} Woohoo! Done.`);
} catch (e) {
logError(`A problem occured. Please check the error above.`);
let checkPkgMsg;
if (versionOnly) {
checkPkgMsg = '';
} else {
const lernaDocsUrl = 'https://github.com/lerna/lerna/tree/master/commands/publish#bump-from-package';
checkPkgMsg = [
` Check the package registry to verify if any packages were published.`,
`If so they may need to be flagged as deprecated since the`,
`${cyan(`lerna publish`)} command exited with error. In some cases it`,
`may be possible to salvage an imcomplete release by using the`,
`${cyan('from-package')} keyword with the ${cyan('lerna publish')}`,
`command. See: ${lernaDocsUrl}`
].join(' ');
}
logError(
failMsg,
`Make sure to clean up the working tree and local/remote commits and`,
`tags as necessary. Check the package registry to verify no packages`,
`were published.`
`Make sure to clean up the working tree and local/remote commits, tags,`,
`and releases as necessary.${checkPkgMsg}`
);
process.exit(1);
}
})();
logSuccess(`${chalk.green(`RELEASE SUCCEEDED!`)} Woohoo! Done.`);
})().then(() => {
process.exit(0);
}).catch(e => {
console.error(e.stack || e);
logError(`A problem occured. Please check the error above.`);
logError(failMsg);
process.exit(1);
});

329
scripts/stable-release.js Normal file
View File

@ -0,0 +1,329 @@
/* global __dirname process require */
const chalk = require('chalk');
const {execSync} = require('child_process');
const minimist = require('minimist');
const path = require('path');
const {readJsonSync} = require('fs-extra');
const semver = require('semver');
const args = minimist(process.argv.slice(2));
const DEFAULT_SKIP_QA = false;
const skipQa = args['skip-qa'] || DEFAULT_SKIP_QA;
const branch = `master`;
const commitMsg = `chore(release): %v`;
const distTag = `latest`;
const remote = `origin`;
const cyan = (str) => chalk.cyan(str);
const execSyncInherit = (cmd) => execSync(cmd, {stdio: 'inherit'});
const log = (mark, str, which = 'log') => console[which](
mark, str.filter(s => !!s).join(` `)
);
const logError = (...str) => log(chalk.red(``), str, 'error');
const logInfo = (...str) => log(chalk.blue(``), str);
const logSuccess = (...str) => log(chalk.green(``), str);
const logWarning = (...str) => log(chalk.yellow('‼︎'), str);
const failMsg = `${chalk.red(`STABLE RELEASE FAILED!`)} Stopping right here.`;
const runCommand = (cmd, inherit = true, display) => {
logInfo(`Running command ${cyan(display || cmd)}.`);
let out;
if (inherit) {
execSyncInherit(cmd);
} else {
out = execSync(cmd);
}
return out;
};
(async () => {
if (!process.env.GH_TOKEN) {
logError(
`Environment variable ${cyan('GH_TOKEN')} was not defined or falsy. It`,
`must be defined with a properly scoped GitHub personal access token.`
);
logError(failMsg);
process.exit(1);
}
const lernaJsonPath = path.join(__dirname, '../lerna.json');
logInfo(`Reading ${cyan(lernaJsonPath)}...`);
let currentVersion, lernaJson, registry;
try {
lernaJson = readJsonSync(lernaJsonPath);
currentVersion = lernaJson.version;
if (!currentVersion) throw new Error('missing version in lerna.json');
registry = lernaJson.command.publish.registry;
if (!registry) throw new Error('missing registry in lerna.json');
} catch (e) {
console.error(e.stack || e);
logError(
`Could not read values from ${cyan(lernaJsonPath)}. Please check the`,
`error above.`
);
logError(failMsg);
process.exit(1);
}
try {
if (!semver(currentVersion).prerelease.length) {
logError(
`Current version in ${cyan('lerna.json')} is not a prerelease. This`,
`script is intended only for graduating a prerelease to a stable`,
`release.`
);
logError(failMsg);
process.exit(1);
}
logSuccess(
`Current version in ${cyan('lerna.json')} is ${cyan(currentVersion)}.`
);
} catch (e) {
logError(`A problem occured. Please check the error above.`);
logError(failMsg);
process.exit(1);
}
logInfo(`Determining the latest stable version...`);
let latestStableVersion;
try {
const stableReleaseTags = runCommand(`git tag --list`, false)
.toString()
.trim()
.split('\n')
.filter(tag => tag.startsWith('v') && !tag.includes('-'))
.map(tag => tag.slice(1));
latestStableVersion = semver.rsort(stableReleaseTags)[0];
if (!latestStableVersion) {
logError(`Unable to determine the latest stable version.`);
logError(failMsg);
process.exit(1);
}
logSuccess(`Latest stable version is ${cyan(latestStableVersion)}.`);
} catch (e) {
logError(`A problem occured. Please check the error above.`);
logError(failMsg);
process.exit(1);
}
let forcePublish;
try {
const currentMajor = semver(currentVersion).major;
const stableMajor = semver(latestStableVersion).major;
if (currentMajor > stableMajor) {
forcePublish = true;
} else if (currentMajor < stableMajor) {
logError(
`Major version of the current version in ${cyan('lerna.json')} is less`,
`than the major version of the latest stable version. wat.`
);
logError(failMsg);
process.exit(1);
}
} catch (e) {
logError(`A problem occured. Please check the error above.`);
logError(failMsg);
process.exit(1);
}
logInfo(`Determining the current branch...`);
let currentBranch;
try {
currentBranch = runCommand(`git rev-parse --abbrev-ref HEAD`, false)
.toString()
.trim();
} catch (e) {
logError(`Could not determine the branch. Please check the error above.`);
logError(failMsg);
process.exit(1);
}
if (currentBranch === branch) {
logSuccess(`Current branch and release branch are the same.`);
} else {
logError(
`Current branch ${cyan(currentBranch)} is not the same as release branch`,
`${cyan(branch)}.`
);
logError(failMsg);
process.exit(1);
}
logInfo(
`Fetching commits from ${cyan(remote)} to compare local and remote`,
`branches...`
);
try {
runCommand(`git fetch ${remote}`, false);
} catch (e) {
logError(`Could not fetch latest commits. Please check the error above.`);
logError(failMsg);
process.exit(1);
}
let localRef, remoteRef;
try {
localRef = runCommand(`git rev-parse ${branch}`, false).toString().trim();
remoteRef = runCommand(`git rev-parse ${remote}/${branch}`, false)
.toString()
.trim();
} catch (e) {
logError(`A problem occured. Please check the error above.`);
logError(failMsg);
process.exit(1);
}
if (localRef === remoteRef) {
logSuccess(`Local branch is in sync with remote branch.`);
} else {
logError(
`Local branch ${cyan(branch)} is not in sync with`,
`${cyan(`${remote}/${branch}`)}.`
);
logError(failMsg);
process.exit(1);
}
if (skipQa) {
logWarning(
`Skipping the QA suite. You already built the packages, right?`
);
} else {
logInfo(
`It's time to run the QA suite, this will take awhile...`
);
try {
runCommand(`npm run qa:full`);
logSuccess(`All steps succeeded in the QA suite.`);
} catch (e) {
logError(`A step failed in the QA suite. Please check the error above.`);
logError(failMsg);
process.exit(1);
}
}
const lernaChanged = [
`lerna`,
`changed`,
(!forcePublish && `--conventional-graduate`) || ``,
(forcePublish && `--force-publish`) || ``,
`--json`,
`--all`
].filter(str => !!str).join(` `);
let pkgsToTag;
try {
pkgsToTag = JSON.parse(
runCommand(`${lernaChanged} 2>/dev/null || true`, false, lernaChanged)
.toString()
.trim() || '[]'
);
} catch (e) {
logError(`A problem occured. Please check the error above.`);
logError(failMsg);
process.exit(1);
}
const lernaPublish = [
`lerna`,
`publish`,
`--conventional-commits`,
(!forcePublish && `--conventional-graduate`) || ``,
`--create-release github`,
`--dist-tag ${distTag}`,
(forcePublish && `--force-publish`) || ``,
`--git-remote ${remote}`,
`--message "${commitMsg}"`,
`--registry ${registry}`
].filter(str => !!str).join(` `);
try {
runCommand(lernaPublish);
if (localRef ===
runCommand(`git rev-parse ${branch}`, false).toString().trim()) {
logWarning(
chalk.yellow(`STABLE RELEASE STOPPED!`),
`No commit or tag was created. No packages were published. This is`,
`most likely due to no qualifying changes having been made to the`,
`${cyan(branch)} branch since the previous release. Please check the`,
`output above.`
);
process.exit(0);
}
} catch (e) {
logError(`A problem occured. Please check the error above.`);
const lernaDocsUrl = 'https://github.com/lerna/lerna/tree/master/commands/publish#bump-from-package';
logError(
failMsg,
`Make sure to clean up commits, tags, and releases as necessary. Check`,
`the package registry to verify if any packages were published. If so`,
`they may need to be flagged as deprecated since the`,
`${cyan('lerna publish')} command exited with error. In some cases it`,
`may be possible to salvage an imcomplete release by using the`,
`${cyan('from-package')} keyword with the ${cyan('lerna publish')}`,
`command. See: ${lernaDocsUrl}`
);
process.exit(1);
}
if (pkgsToTag.length) {
logInfo(`Reading ${cyan(lernaJsonPath)}...`);
let updatedCurrentVersion;
try {
lernaJson = readJsonSync(lernaJsonPath);
updatedCurrentVersion = lernaJson.version;
if (!updatedCurrentVersion) throw new Error('missing version in lerna.json');
logSuccess(`Updated current version is ${updatedCurrentVersion}`);
} catch (e) {
console.error(e.stack || e);
logError(
`Could not read values from ${cyan(lernaJsonPath)}. Please check the`,
`error above.`
);
logError(failMsg);
process.exit(1);
}
logInfo(
`Updating ${cyan('nightly')} dist-tags to point to the new stable`,
`version...`
);
logInfo('Packages to tag:', pkgsToTag.map(({name}) => cyan(name)).join(', '));
const _pkgsToTag = pkgsToTag.slice();
try {
for (const {name} of pkgsToTag) {
runCommand(`npm dist-tag add ${name}@${updatedCurrentVersion} nightly`);
_pkgsToTag.shift();
}
} catch (e) {
logError(`A problem occured. Please check the error above.`);
const packages = _pkgsToTag.map(({name}) => cyan(name)).join(', ');
logError(
`NPM dist-tag ${cyan('nightly')} was not updated for the following`,
`packages: ${packages}. Make sure to complete the updates manually.`
);
logError(failMsg);
process.exit(1);
}
}
logSuccess(`${chalk.green(`STABLE RELEASE SUCCEEDED!`)} Woohoo! Done.`);
})().then(() => {
process.exit(0);
}).catch(e => {
console.error(e.stack || e);
logError(`A problem occured. Please check the error above.`);
logError(failMsg);
process.exit(1);
});