Repository: intuit/auto
Pull request #1031 (2020-03-06 18:09:22)

Add GitHub Pages Plugin
# What Changed

Auto now has a build in plugin to publish to gh-pages. This plugin can work on any repo and is super flexible.

# Why

I end up doing this configuration a lot anyway. And releasing the docs site is something that should be automated. This plugin allows you to use whatever tool you want to build your docs website.

Todo:

- [x] Add tests
- [x] Add docs

<!-- GITHUB_RELEASE PR BODY: canary-version -->
<details>
  <summary>📦 Published PR as canary version: <code>under canary scope @auto-canary@9.17.0-canary.1031.13589.0</code></summary>
  <br />
  
  :sparkles: Test out this PR locally via:
  
  ```sh
  npm install @auto-canary/bot-list@9.17.0-canary.1031.13589.0
  npm install @auto-canary/auto@9.17.0-canary.1031.13589.0
  npm install @auto-canary/core@9.17.0-canary.1031.13589.0
  npm install @auto-canary/all-contributors@9.17.0-canary.1031.13589.0
  npm install @auto-canary/chrome@9.17.0-canary.1031.13589.0
  npm install @auto-canary/conventional-commits@9.17.0-canary.1031.13589.0
  npm install @auto-canary/crates@9.17.0-canary.1031.13589.0
  npm install @auto-canary/first-time-contributor@9.17.0-canary.1031.13589.0
  npm install @auto-canary/gh-pages@9.17.0-canary.1031.13589.0
  npm install @auto-canary/git-tag@9.17.0-canary.1031.13589.0
  npm install @auto-canary/gradle@9.17.0-canary.1031.13589.0
  npm install @auto-canary/jira@9.17.0-canary.1031.13589.0
  npm install @auto-canary/maven@9.17.0-canary.1031.13589.0
  npm install @auto-canary/npm@9.17.0-canary.1031.13589.0
  npm install @auto-canary/omit-commits@9.17.0-canary.1031.13589.0
  npm install @auto-canary/omit-release-notes@9.17.0-canary.1031.13589.0
  npm install @auto-canary/released@9.17.0-canary.1031.13589.0
  npm install @auto-canary/s3@9.17.0-canary.1031.13589.0
  npm install @auto-canary/slack@9.17.0-canary.1031.13589.0
  npm install @auto-canary/twitter@9.17.0-canary.1031.13589.0
  npm install @auto-canary/upload-assets@9.17.0-canary.1031.13589.0
  # or 
  yarn add @auto-canary/bot-list@9.17.0-canary.1031.13589.0
  yarn add @auto-canary/auto@9.17.0-canary.1031.13589.0
  yarn add @auto-canary/core@9.17.0-canary.1031.13589.0
  yarn add @auto-canary/all-contributors@9.17.0-canary.1031.13589.0
  yarn add @auto-canary/chrome@9.17.0-canary.1031.13589.0
  yarn add @auto-canary/conventional-commits@9.17.0-canary.1031.13589.0
  yarn add @auto-canary/crates@9.17.0-canary.1031.13589.0
  yarn add @auto-canary/first-time-contributor@9.17.0-canary.1031.13589.0
  yarn add @auto-canary/gh-pages@9.17.0-canary.1031.13589.0
  yarn add @auto-canary/git-tag@9.17.0-canary.1031.13589.0
  yarn add @auto-canary/gradle@9.17.0-canary.1031.13589.0
  yarn add @auto-canary/jira@9.17.0-canary.1031.13589.0
  yarn add @auto-canary/maven@9.17.0-canary.1031.13589.0
  yarn add @auto-canary/npm@9.17.0-canary.1031.13589.0
  yarn add @auto-canary/omit-commits@9.17.0-canary.1031.13589.0
  yarn add @auto-canary/omit-release-notes@9.17.0-canary.1031.13589.0
  yarn add @auto-canary/released@9.17.0-canary.1031.13589.0
  yarn add @auto-canary/s3@9.17.0-canary.1031.13589.0
  yarn add @auto-canary/slack@9.17.0-canary.1031.13589.0
  yarn add @auto-canary/twitter@9.17.0-canary.1031.13589.0
  yarn add @auto-canary/upload-assets@9.17.0-canary.1031.13589.0
  ```
</details>
<!-- GITHUB_RELEASE PR BODY: canary-version -->



Patch:
diff --git a/.circleci/config.yml b/.circleci/config.yml
index 210c9d1c..7e162c0a 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -86,21 +86,10 @@ jobs:
     steps:
       - attach_workspace:
           at: ~/auto
-      - run: mkdir ~/.ssh/ && echo -e "Host github.com\n\tStrictHostKeyChecking no\n" > ~/.ssh/config
       - run:
           name: Release
           command: yarn auto shipit -vv
 
-  publishDocs:
-    <<: *defaults
-    steps:
-      - attach_workspace:
-          at: ~/auto
-      - run: mkdir ~/.ssh/ && echo -e "Host github.com\n\tStrictHostKeyChecking no\n" > ~/.ssh/config
-      - run:
-          name: Publish Docs
-          command: yarn docs:publish
-
 workflows:
   version: 2
   build_and_test:
@@ -124,11 +113,3 @@ workflows:
           requires:
             - test
             - lint
-
-      - publishDocs:
-          requires:
-            - release
-          filters:
-            branches:
-              only:
-                - master
diff --git a/README.md b/README.md
index 6f6c71ae..6f165910 100644
--- a/README.md
+++ b/README.md
@@ -49,6 +49,7 @@ Auto has an extensive plugin system and wide variety of official plugins. Make a
 - [all-contributors](./plugins/all-contributors) - Automatically add contributors as changelogs are produced using [`all-contributors-cli](https://www.npmjs.com/package/all-contributors-cli)
 - [conventional-commits](./plugins/conventional-commits) - Parse conventional commit messages for version bumps
 - [first-time-contributor](./plugins/first-time-contributor) - Thank first time contributors for their work right in your release notes.
+- [gh-pages](./plugins/gh-pages) - Automate publishing to your gh-pages documentation website
 - [jira](./plugins/jira) - Include Jira story links in the changelog
 - [omit-commits](./plugins/omit-commits) - Ignore commits base on name, email, subject, labels, and username
 - [omit-release-notes](./plugins/omit-release-notes) - Ignore release notes in PRs made by certain accounts
@@ -199,6 +200,7 @@ Thanks goes to these wonderful people ([emoji key](https://github.com/kentcdodds
 
 <!-- markdownlint-enable -->
 <!-- prettier-ignore-end -->
+
 <!-- ALL-CONTRIBUTORS-LIST:END -->
 
 This project follows the [all-contributors](https://github.com/kentcdodds/all-contributors) specification. Contributions of any kind welcome!
diff --git a/docs/index.md b/docs/index.md
index e009e658..b2158f3a 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -45,8 +45,8 @@ Package Manager Plugins
 - [Crates](../plugins/crates/README.md)
 - [Git Tag](../plugins/git-tag/README.md)
 - [Gradle](../plugins/gradle/README.md)
-- [NPM](../plugins/npm/README.md)
 - [Maven](../plugins/maven/README.md)
+- [NPM](../plugins/npm/README.md)
 
 Functionality Plugins
 
@@ -54,6 +54,7 @@ Functionality Plugins
 - [Amazon S3](../plugins/s3/README.md)
 - [Conventional Commits](../plugins/conventional-commits/README.md)
 - [First Time Contributor](../plugins/first-time-contributor/README.md)
+- [GitHub Pages](../plugins/gh-pages/README.md)
 - [Jira](../plugins/jira/README.md)
 - [Omit Commits](../plugins/omit-commits/README.md)
 - [Omit Release Notes](../plugins/omit-release-notes/README.md)
diff --git a/package.json b/package.json
index 722a772d..084ca8fe 100644
--- a/package.json
+++ b/package.json
@@ -64,6 +64,7 @@
     "prettier": "^1.19.1",
     "push-dir": "^0.4.1",
     "rimraf": "^3.0.0",
+    "title-case": "^3.0.2",
     "ts-jest": "^25.0.0",
     "typescript": "~3.7.2"
   },
@@ -156,6 +157,13 @@
           "./packages/cli/binary/auto-macos.gz",
           "./packages/cli/binary/auto-win.exe.gz"
         ]
+      ],
+      [
+        "gh-pages",
+        {
+          "buildCommand": "yarn docs",
+          "dir": "_ignite/auto"
+        }
       ]
     ],
     "labels": [
diff --git a/packages/core/src/auto.ts b/packages/core/src/auto.ts
index c0e5544e..886ab318 100644
--- a/packages/core/src/auto.ts
+++ b/packages/core/src/auto.ts
@@ -366,7 +366,7 @@ export default class Auto {
       }
     );
 
-    /** 
+    /**
      * Determine if repo is behind HEAD of current branch. We do this in
      * the "afterVersion" hook so the check happens as late as possible.
      */
@@ -1472,7 +1472,7 @@ export default class Auto {
   }
 
   /** Calculate a version from a tag using labels */
-  private async getVersion({ from }: IVersionOptions = {}) {
+  async getVersion({ from }: IVersionOptions = {}) {
     if (!this.git || !this.release) {
       throw this.createErrorMessage();
     }
@@ -1717,7 +1717,7 @@ export default class Auto {
   /**
    * Set the git user to make releases and commit with.
    */
-  private async setGitUser() {
+  async setGitUser() {
     const user = await this.getGitUser();
 
     if (user && !user.system) {
diff --git a/packages/core/src/git.ts b/packages/core/src/git.ts
index 8148fb78..54b414c9 100644
--- a/packages/core/src/git.ts
+++ b/packages/core/src/git.ts
@@ -13,10 +13,12 @@ import endent from 'endent';
 import { Memoize as memoize } from 'typescript-memoize';
 
 import { ILabelDefinition } from './release';
+import verifyAuth from './utils/verify-auth';
 import execPromise from './utils/exec-promise';
 import { dummyLog, ILogger } from './utils/logger';
 import { gt } from 'semver';
 import { ICommit } from './log-parse';
+import { buildSearchQuery, ISearchResult } from './match-sha-to-pr';
 
 const gitlog = promisify(gitlogNode);
 
@@ -137,19 +139,7 @@ export default class Git {
 
   /** Verify the write access authorization to remote repository with push dry-run. */
   async verifyAuth(url: string) {
-    try {
-      await execPromise('git', [
-        'push',
-        '--dry-run',
-        '--no-verify',
-        url,
-        `HEAD:${this.options.baseBranch}`,
-        '-q'
-      ]);
-      return true;
-    } catch (error) {
-      return false;
-    }
+    return verifyAuth(url, this.options.baseBranch);
   }
 
   /** Get the "Latest Release" from GitHub */
@@ -833,4 +823,29 @@ export default class Git {
 
     return firstGreatestUnique;
   }
+
+  /** Determine the pull request for a commit hash */
+  async matchCommitToPr(sha: string) {
+    const query = buildSearchQuery(this.options.owner, this.options.repo, [
+      sha
+    ]);
+
+    if (!query) {
+      return;
+    }
+
+    const key = `hash_${sha}`;
+    const result = (await this.graphql(query)) as Record<string, ISearchResult>;
+
+    if (!result || !result[key] || !result[key].edges[0]) {
+      return;
+    }
+
+    const pr = result[key].edges[0].node;
+
+    return {
+      ...pr,
+      labels: pr.labels ? pr.labels.edges.map(edge => edge.node.name) : []
+    };
+  }
 }
diff --git a/packages/core/src/utils/verify-auth.ts b/packages/core/src/utils/verify-auth.ts
new file mode 100644
index 00000000..9325f9a6
--- /dev/null
+++ b/packages/core/src/utils/verify-auth.ts
@@ -0,0 +1,32 @@
+import { spawn } from 'child_process';
+
+/**
+ *
+ */
+export default function verifyAuth(remote: string, branch: string) {
+  return new Promise<boolean>(resolve => {
+    try {
+      const child = spawn(
+        `git push --dry-run --no-verify ${remote} HEAD:${branch} -q`,
+        {
+          cwd: process.cwd(),
+          env: process.env,
+          detached: true,
+          shell: true
+        }
+      );
+
+      let err = '';
+
+      child.stderr.on('data', data => {
+        err += data.toString();
+      });
+
+      child.on('exit', () => {
+        resolve(!err.startsWith('fatal: could not read Username'));
+      });
+    } catch (error) {
+      resolve(false);
+    }
+  });
+}
diff --git a/plugins/gh-pages/README.md b/plugins/gh-pages/README.md
new file mode 100644
index 00000000..9e4c0eb4
--- /dev/null
+++ b/plugins/gh-pages/README.md
@@ -0,0 +1,88 @@
+# GitHub Pages Plugin
+
+Automate publishing to your gh-pages documentation website
+
+`auto` will push a directory to your `gh-pages` branch:
+
+1. on every release
+2. if the `documentation` label is on the pull request
+
+## Installation
+
+This plugin is not included with the `auto` CLI installed via NPM. To install:
+
+```sh
+npm i --save-dev @auto-it/gh-pages
+# or
+yarn add -D @auto-it/gh-pages
+```
+
+## Usage
+
+All you need to do a provide that path to your build documentation website.
+
+```json
+{
+  "plugins": [
+    ["gh-pages", { "dir": "./path/to/built/docs/website" }]
+    // other plugins
+  ]
+}
+```
+
+### Build Your Docs
+
+If you also want `auto` to run the build for you docs site provide the `buildCommand` option.
+
+```json
+{
+  "plugins": [
+    [
+      "gh-pages",
+      {
+        "buildCommand": "npm run build:docs",
+        "dir": "./path/to/built/docs/website"
+      }
+    ]
+    // other plugins
+  ]
+}
+```
+
+### Pages Branch
+
+You can configure the branch `auto` pushes to with the `branch` option.
+
+```json
+{
+  "plugins": [
+    [
+      "gh-pages",
+      {
+        "branch": "docs",
+        "dir": "./path/to/built/docs/website"
+      }
+    ]
+    // other plugins
+  ]
+}
+```
+
+### Documentation Label
+
+You can configure the label `auto` looks for with the `label` option.
+
+```json
+{
+  "plugins": [
+    [
+      "gh-pages",
+      {
+        "label": "website",
+        "dir": "./path/to/built/docs/website"
+      }
+    ]
+    // other plugins
+  ]
+}
+```
diff --git a/plugins/gh-pages/package.json b/plugins/gh-pages/package.json
new file mode 100644
index 00000000..c1eb2233
--- /dev/null
+++ b/plugins/gh-pages/package.json
@@ -0,0 +1,45 @@
+{
+  "name": "@auto-it/gh-pages",
+  "version": "9.16.6",
+  "main": "dist/index.js",
+  "description": "Automate publishing to your gh-pages documentation website",
+  "license": "MIT",
+  "author": {
+    "name": "Andrew Lisowski",
+    "email": "lisowski54@gmail.com"
+  },
+  "publishConfig": {
+    "registry": "https://registry.npmjs.org/",
+    "access": "public"
+  },
+  "repository": {
+    "type": "git",
+    "url": "https://github.com/intuit/auto"
+  },
+  "files": [
+    "dist"
+  ],
+  "keywords": [
+    "automation",
+    "semantic",
+    "release",
+    "github",
+    "labels",
+    "automated",
+    "continuos integration",
+    "changelog"
+  ],
+  "scripts": {
+    "build": "tsc -b",
+    "start": "npm run build -- -w",
+    "lint": "eslint src --ext .ts",
+    "test": "jest --maxWorkers=2 --config ../../package.json"
+  },
+  "dependencies": {
+    "@auto-it/core": "link:../../packages/core",
+    "fp-ts": "^2.5.3",
+    "io-ts": "^2.1.2",
+    "push-dir": "^0.4.1",
+    "tslib": "1.10.0"
+  }
+}
diff --git a/plugins/gh-pages/src/index.ts b/plugins/gh-pages/src/index.ts
new file mode 100644
index 00000000..88b6f711
--- /dev/null
+++ b/plugins/gh-pages/src/index.ts
@@ -0,0 +1,125 @@
+import {
+  Auto,
+  IPlugin,
+  execPromise,
+  validatePluginConfiguration,
+  SEMVER
+} from '@auto-it/core';
+import { execSync } from 'child_process';
+import * as t from 'io-ts';
+
+const required = t.interface({
+  /** The directory to push to gh-pages */
+  dir: t.string
+});
+
+const optional = t.partial({
+  /** A command to build the documenation website */
+  buildCommand: t.string,
+  /** The branch to push to */
+  branch: t.string,
+  /** A label to look for and always publish the docs */
+  label: t.string
+});
+
+const pluginOptions = t.intersection([required, optional]);
+
+export type IGhPagesPluginOptions = t.TypeOf<typeof pluginOptions>;
+
+const defaults = {
+  branch: 'gh-pages',
+  label: 'documentation'
+};
+
+/** Automate publishing to your gh-pages documentation website. */
+export default class GhPagesPlugin implements IPlugin {
+  /** The name of the plugin */
+  name = 'gh-pages';
+
+  /** The options of the plugin */
+  readonly options: IGhPagesPluginOptions & typeof defaults;
+
+  /** Initialize the plugin with it's options */
+  constructor(options: IGhPagesPluginOptions) {
+    this.options = { ...defaults, ...options };
+  }
+
+  /** Tap into auto plugin points. */
+  apply(auto: Auto) {
+    auto.hooks.validateConfig.tapPromise(this.name, async (name, options) => {
+      if (name === this.name || name === `@auto-it/${this.name}`) {
+        return validatePluginConfiguration(this.name, pluginOptions, options);
+      }
+    });
+
+    auto.hooks.beforeShipIt.tapPromise(this.name, async ({ releaseType }) => {
+      if (releaseType !== 'latest' || !auto.git) {
+        return;
+      }
+
+      const bump = await auto.getVersion();
+
+      // If it's a bump the 'afterRelease' hook will release the docs
+      if (bump !== SEMVER.noVersion) {
+        return;
+      }
+
+      const sha = await auto.git.getSha();
+      const pr = await auto.git.matchCommitToPr(sha);
+
+      if (!pr) {
+        return;
+      }
+
+      const hasDocumentationLabel = pr.labels.includes(this.options.label);
+
+      if (!hasDocumentationLabel) {
+        return;
+      }
+
+      // If: skip-release + w/documentation label then we will push to gh-pages
+      await auto.setGitUser();
+      await this.releaseGhPages(auto);
+    });
+
+    auto.hooks.afterRelease.tapPromise(this.name, async ({ response }) => {
+      if (!response) {
+        return;
+      }
+
+      const releases = Array.isArray(response) ? response : [response];
+      const isPrerelease = releases.some(release => release.data.prerelease);
+
+      if (isPrerelease) {
+        return;
+      }
+
+      await this.releaseGhPages(auto);
+    });
+  }
+
+  /** Release to gh-pages */
+  private async releaseGhPages(auto: Auto) {
+    if (this.options.buildCommand) {
+      execSync(this.options.buildCommand);
+    }
+
+    try {
+      await execPromise('npx', [
+        'push-dir',
+        '--cleanup',
+        `--remote=${auto.remote}`,
+        `--dir=${this.options.dir}`,
+        `--branch=${this.options.branch}`,
+        '--message="Update docs [skip ci]"'
+      ]);
+    } catch (error) {
+      auto.logger.log.error(
+        'Oh no! It looks like there was trouble publishing to GitHub Pages 😢'
+      );
+      throw error;
+    }
+
+    auto.logger.log.success('Successfully deployed to GitHub Pages!');
+  }
+}
diff --git a/plugins/gh-pages/tsconfig.json b/plugins/gh-pages/tsconfig.json
new file mode 100644
index 00000000..bfbef6fc
--- /dev/null
+++ b/plugins/gh-pages/tsconfig.json
@@ -0,0 +1,16 @@
+{
+  "extends": "../../tsconfig.json",
+  "include": ["src/**/*", "../../typings/**/*"],
+
+  "compilerOptions": {
+    "outDir": "./dist",
+    "rootDir": "./src",
+    "composite": true
+  },
+
+  "references": [
+    {
+      "path": "../../packages/core"
+    }
+  ]
+}
diff --git a/scripts/create-plugin.js b/scripts/create-plugin.js
index 311ff10a..857492ad 100755
--- a/scripts/create-plugin.js
+++ b/scripts/create-plugin.js
@@ -5,6 +5,7 @@ const path = require('path');
 const log = require('signale');
 const fs = require('fs');
 const changeCase = require('change-case');
+const { titleCase } = require('title-case');
 const [, , name, description] = process.argv;
 
 const { version } = JSON.parse(
@@ -20,7 +21,7 @@ fs.mkdirSync(outDir);
 const vars = {
   description,
   version,
-  title: changeCase.titleCase(name),
+  title: titleCase(name),
   kebab,
   pascal: changeCase.pascalCase(name)
 };
diff --git a/scripts/publish-docs.sh b/scripts/publish-docs.sh
deleted file mode 100755
index d1f34330..00000000
--- a/scripts/publish-docs.sh
+++ /dev/null
@@ -1,14 +0,0 @@
-export PATH=$(npm bin):$PATH
-
-SHOULD_PUBLISH=`git diff \`git tag --sort version:refname | tail -n 1\` HEAD --name-only | grep -F 'docs/'`
-
-if [ ! -z "$SHOULD_PUBLISH" ]; then
-  echo publishing $SHOULD_PUBLISH
-  ./docs/generate-command-docs.js
-  ignite
-  git config --global user.name "Andrew Lisowski"
-  git config --global user.email "lisowski54@gmail.com"
-  npx push-dir --cleanup --dir=_ignite/auto --branch=gh-pages --message "Update docs [skip ci]"
-else
-  echo No documentation files changed since last tagged release.
-fi
\ No newline at end of file
diff --git a/scripts/template-plugin/src/index.ts b/scripts/template-plugin/src/index.ts
index 883d592e..a625462f 100644
--- a/scripts/template-plugin/src/index.ts
+++ b/scripts/template-plugin/src/index.ts
@@ -2,16 +2,17 @@ import { Auto, IPlugin } from '@auto-it/core';
 
 interface I{{pascal}}PluginOptions {}
 
+/** {{description}} */
 export default class {{pascal}}Plugin implements IPlugin {
   /** The name of the plugin */
-  name = '{{title}}';
+  name = '{{kebab}}';
 
   /** The options of the plugin */
   readonly options: I{{pascal}}PluginOptions;
 
   /** Initialize the plugin with it's options */
   constructor(options: I{{pascal}}PluginOptions) {
-    this.options = {};
+    this.options = options;
   }
 
   /** Tap into auto plugin points. */
diff --git a/tsconfig.dev.json b/tsconfig.dev.json
index 9c5aff7d..13aa2a5c 100644
--- a/tsconfig.dev.json
+++ b/tsconfig.dev.json
@@ -52,6 +52,9 @@
     },
     {
       "path": "plugins/all-contributors"
+    },
+    {
+      "path": "plugins/gh-pages"
     }
   ]
 }
diff --git a/yarn.lock b/yarn.lock
index 1e04d266..db9364b7 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -8,11 +8,12 @@
   integrity sha512-m2OzlTDbhvzK4hgswH9Lx0cdpq1AntXu53Klz5RGkFfuoDvKsnlU7tmtVfNWtUiP0zZbGtrb/BZYH7aB6TRnMA==
 
 "@auto-it/bot-list@link:packages/bot-list":
-  version "9.15.8"
+  version "9.16.6"
 
 "@auto-it/core@link:packages/core":
-  version "9.16.0"
+  version "9.16.6"
   dependencies:
+    "@auto-it/bot-list" "link:packages/bot-list"
     "@octokit/core" "2.4.0"
     "@octokit/graphql" "4.3.1"
     "@octokit/plugin-enterprise-compatibility" "1.2.1"
@@ -36,6 +37,7 @@
     lodash.chunk "^4.2.0"
     log-symbols "^3.0.0"
     node-fetch "2.6.0"
+    parse-github-url "1.0.2"
     semver "^7.0.0"
     signale "^1.4.0"
     tapable "^2.0.0-beta.2"
@@ -46,7 +48,7 @@
     url-join "^4.0.0"
 
 "@auto-it/npm@link:plugins/npm":
-  version "9.16.0"
+  version "9.16.6"
   dependencies:
     "@auto-it/core" "link:packages/core"
     env-ci "^5.0.1"
@@ -63,7 +65,7 @@
     user-home "^2.0.0"
 
 "@auto-it/released@link:plugins/released":
-  version "9.16.0"
+  version "9.16.6"
   dependencies:
     "@auto-it/core" "link:packages/core"
     deepmerge "^4.0.0"
@@ -14817,6 +14819,13 @@ tinycolor2@^1.1.2, tinycolor2@^1.4.1:
   resolved "https://registry.yarnpkg.com/tinycolor2/-/tinycolor2-1.4.1.tgz#f4fad333447bc0b07d4dc8e9209d8f39a8ac77e8"
   integrity sha1-9PrTM0R7wLB9TcjpIJ2POaisd+g=
 
+title-case@^3.0.2:
+  version "3.0.2"
+  resolved "https://registry.yarnpkg.com/title-case/-/title-case-3.0.2.tgz#9f926a0a42071366f85470572f312c4b647773ab"
+  integrity sha512-1P5hyjEhJ9Ab0AT8Xbm0z1avwPSgRR6XtFSNCdfo6B7111TTTja+456UZ2ZPkbTbzqBwIpQxp/tazh5UvpJ+fA==
+  dependencies:
+    tslib "^1.10.0"
+
 tmp@^0.0.33:
   version "0.0.33"
   resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.0.33.tgz#6d34335889768d21b2bcda0aa277ced3b1bfadf9"


Test patch:
diff --git a/packages/core/src/__tests__/auto.test.ts b/packages/core/src/__tests__/auto.test.ts
index caba5653..836d62e2 100644
--- a/packages/core/src/__tests__/auto.test.ts
+++ b/packages/core/src/__tests__/auto.test.ts
@@ -7,7 +7,9 @@ import loadPlugin from '../utils/load-plugins';
 import child from 'child_process';
 
 const importMock = jest.fn();
+
 jest.mock('../utils/load-plugins.ts');
+jest.mock('../utils/verify-auth.ts', () => () => true);
 jest.mock('import-cwd', () => (path: string) => importMock(path));
 jest.mock('env-ci', () => () => ({ isCi: false, branch: 'master' }));
 
diff --git a/packages/core/src/utils/__tests__/verify-auth.test.ts b/packages/core/src/utils/__tests__/verify-auth.test.ts
new file mode 100644
index 00000000..1fb626f0
--- /dev/null
+++ b/packages/core/src/utils/__tests__/verify-auth.test.ts
@@ -0,0 +1,36 @@
+import verifyAuth from '../verify-auth';
+import childProcess from 'child_process';
+
+const spawn = childProcess.spawn as jest.Mock;
+jest.mock('child_process');
+
+describe('verify-auth', () => {
+  test('should handle error', async () => {
+    spawn.mockImplementationOnce(() => ({
+      stderr: { on: () => {} },
+      on: () => {
+        throw new Error();
+      }
+    }));
+    expect(await verifyAuth('origin', 'master')).toBe(false);
+  });
+
+  test('should verify auth when we can push to remote', async () => {
+    spawn.mockImplementationOnce(() => ({
+      stderr: { on: () => {} },
+      on: (_: string, cb: () => void) => cb()
+    }));
+    expect(await verifyAuth('origin', 'master')).toBe(true);
+  });
+
+  test("should not verify auth when we can't push to remote", async () => {
+    spawn.mockImplementationOnce(() => ({
+      stderr: {
+        on: (_: string, cb: (data: string) => void) =>
+          cb('fatal: could not read Username')
+      },
+      on: (_: string, cb: () => void) => cb()
+    }));
+    expect(await verifyAuth('bad', 'master')).toBe(false);
+  });
+});
diff --git a/plugins/gh-pages/__tests__/gh-pages.test.ts b/plugins/gh-pages/__tests__/gh-pages.test.ts
new file mode 100644
index 00000000..1e8e3270
--- /dev/null
+++ b/plugins/gh-pages/__tests__/gh-pages.test.ts
@@ -0,0 +1,140 @@
+import { execPromise } from '@auto-it/core';
+import GhPages, { IGhPagesPluginOptions } from '../src';
+import { makeHooks } from '@auto-it/core/dist/utils/make-hooks';
+import { dummyLog } from '@auto-it/core/dist/utils/logger';
+import { execSync } from 'child_process';
+
+const execSyncSpy = execSync as jest.Mock;
+jest.mock('child_process');
+
+const execSpy = execPromise as jest.Mock;
+jest.mock('@auto-it/core');
+
+/** Create a test for gh-pages plugin */
+function createTest(
+  options: IGhPagesPluginOptions,
+  autoOverrides: Record<string, any> = {}
+) {
+  const releasedLabel = new GhPages(options);
+  const hooks = makeHooks();
+
+  releasedLabel.apply({
+    hooks,
+    logger: dummyLog(),
+    setGitUser: () => undefined,
+    ...autoOverrides,
+    git: autoOverrides.git || {}
+  } as any);
+
+  return hooks;
+}
+
+describe('Gh-Pages Plugin', () => {
+  beforeEach(() => {
+    execSpy.mockReset();
+  });
+
+  describe('beforeShipit', () => {
+    test('should not release on anything but a latest release', async () => {
+      const hooks = createTest({ dir: 'test' });
+
+      await hooks.beforeShipIt.promise({ releaseType: 'canary' });
+      await hooks.beforeShipIt.promise({ releaseType: 'next' });
+      await hooks.beforeShipIt.promise({ releaseType: 'old' });
+
+      expect(execSpy).not.toHaveBeenCalled();
+    });
+
+    test('should not release if there is a version bump', async () => {
+      const hooks = createTest({ dir: 'test' }, { getVersion: () => 'patch' });
+
+      await hooks.beforeShipIt.promise({ releaseType: 'latest' });
+      expect(execSpy).not.toHaveBeenCalled();
+    });
+
+    test('should not release if there is no matching PR', async () => {
+      const hooks = createTest(
+        { dir: 'test' },
+        {
+          getVersion: () => '',
+          git: { getSha: () => '123', matchCommitToPr: () => undefined }
+        }
+      );
+
+      await hooks.beforeShipIt.promise({ releaseType: 'latest' });
+      expect(execSpy).not.toHaveBeenCalled();
+    });
+
+    test("should not release if PR doesn't have documentation label", async () => {
+      const hooks = createTest(
+        { dir: 'test' },
+        {
+          getVersion: () => '',
+          git: { getSha: () => '123', matchCommitToPr: () => ({ labels: [] }) }
+        }
+      );
+
+      await hooks.beforeShipIt.promise({ releaseType: 'latest' });
+      expect(execSpy).not.toHaveBeenCalled();
+    });
+
+    test('should release if noVersion + documentation label', async () => {
+      const hooks = createTest(
+        { dir: 'test' },
+        {
+          getVersion: () => '',
+          git: {
+            getSha: () => '123',
+            matchCommitToPr: () => ({ labels: ['documentation'] })
+          }
+        }
+      );
+
+      await hooks.beforeShipIt.promise({ releaseType: 'latest' });
+      expect(execSpy).toHaveBeenCalled();
+    });
+  });
+
+  describe('afterRelease', () => {
+    test('should not release if there is not GitHub release', async () => {
+      const hooks = createTest({ dir: 'test' });
+      await hooks.afterRelease.promise({ response: undefined } as any);
+      expect(execSpy).not.toHaveBeenCalled();
+    });
+
+    test('should not release if it was pre-release', async () => {
+      const hooks = createTest({ dir: 'test' });
+      await hooks.afterRelease.promise({
+        response: { data: { prerelease: true } }
+      } as any);
+      expect(execSpy).not.toHaveBeenCalled();
+    });
+
+    test('should run build command if present', async () => {
+      const buildCommand = 'dew it';
+      const hooks = createTest({
+        dir: 'test',
+        buildCommand
+      });
+      await hooks.afterRelease.promise({
+        response: { data: { prerelease: false } }
+      } as any);
+      expect(execSyncSpy).toHaveBeenCalledWith(buildCommand);
+    });
+
+    test('should successfully deploy to gh-pages', async () => {
+      const hooks = createTest({ dir: 'test' });
+      await hooks.afterRelease.promise({
+        response: { data: { prerelease: false } }
+      } as any);
+      expect(execSpy).toHaveBeenCalledWith('npx', [
+        'push-dir',
+        '--cleanup',
+        '--remote=undefined',
+        '--dir=test',
+        '--branch=gh-pages',
+        '--message="Update docs [skip ci]"'
+      ]);
+    });
+  });
+});