30. December 2017

Deploying to GitHub Pages using Circle CI 2.0

GitHub Pages is GitHub’s free service for hosting static websites (like this blog) and assets. Its usage is extremly straight forward but requires little repetitive chunks of manual work each time you want to update something (unless you use their built-in jekyll tool). The number of smallish and static side projects I maintain recently reached critical mass, so I looked into using Circle CI for automating the deployment to GitHub pages.


Publishing sources

GitHub pages lets you specify different locations where it can serve the assets from:

  • The root of your repository on the master branch
  • A /docs folder on the master branch
  • The root of a dedicated branch called gh-pages

Most people, including me, use the gh-pages option and set the branch up so that it is a detached orphan branch. This lets you nicely separate your sources, kept on master and other branches, and the rendered output which is being isolated on gh-pages.

When setting up Continuous Deployment in this scenario a push to master should therefore trigger the build and replace the last revision of gh-pages with the build content so that GitHub pages knows it should deploy the updates.

Granting repository access to Circle CI

This of course means Circle CI needs to be be able to push to your repository. Circle CI has automated this process and lets you automatically create a User Key using any account that has access to the repository. This key is kept semi-secret - only the fingerprint will be visible in the UI. It is available to your deployment script though, so make sure you do not run untrusted or unknown code when deploying. If you need fine grained access control it is probably a good idea to create a dedicated GitHub user (“Me-DeployBot”) for this, so you can grant access only where needed.

In addition to that your deployment script will need to know about the username and email that is associated with your User Key. Those are best stored in a GH_NAME and GH_EMAIL environment variable that you can define either in your config.yml or in the UI of Circle CI. This lets you use the following in your deployment script:

git config --global user.email $GH_EMAIL
git config --global user.name $GH_NAME

Creating a second clone of your repository

The next thing to do is to create a clone-in-clone of the repository you are working with so that you can check out its gh-pages branch. The example uses a folder called out to contain this copy, but you can call it anything you like.

git clone $CIRCLE_REPOSITORY_URL out

cd out
git checkout $TARGET_BRANCH || git checkout --orphan $TARGET_BRANCH
git rm -rf .

The last command makes sure you can do a fresh build in case the script has checked out a new orphan branch from master. In this case it would still contain an unwanted copy of all the tracked files.

Building into your target folder

Assuming that make builds your project (this can be any command that fits your project, npm scripts, rake, gulp, a bash script, anything) into a folder called build you can now do:

make
cp -a build/. out/.
mkdir -p out/.circleci && cp -a .circleci/. out/.circleci/.

There is an important detail hidden in the last line. If Circle CI is configured to watch your repository, it will also try to build your gh-pages branch. Usually you would ignore such a branch in your config.ymls branches.ignore section, yet on gh-pages there will probably be no config file yet (we deleted everything before), so Circle CI will not find any configuration and try to build your project using inferred defaults. Copying your .circleci folder will fix this issue for you as your build now knows which branches to ignore. You can ignore a branch in your config.yml like this:

branches:
  ignore:
    - gh-pages

Pushing the build to your repository

Pushing the sources is now as simple as:

cd out
git add -A
git commit -m "Automated deployment to GitHub Pages: ${CIRCLE_SHA1}" --allow-empty
git push origin $TARGET_BRANCH

The commit message will refer to the latest commit on your master branch (which I find to be quite handy), but you can adopt any scheme you wish.

GitHub Pages will now build your site and deploy the updates. ZOMFG.

A full configuration and script example

The config.yml that is currently building and deploying this blog looks like:

version: 2
jobs:
  build:
    branches:
      ignore:
        - gh-pages
    docker:
      - image: circleci/node:8
    working_directory: ~/repo
    environment:
      - SOURCE_BRANCH: master
      - TARGET_BRANCH: gh-pages
    steps:
      - checkout
      - restore_cache:
          keys:
          - v1-dependencies-{{ checksum "package.json" }}
          - v1-dependencies-
      - run:
          name: Install dependencies
          command: npm install
      - save_cache:
          paths:
            - node_modules
          key: v1-dependencies-{{ checksum "package.json" }}
      - run:
          name: Run tests
          command: npm test
      - deploy:
          name: Deploy
          command: |
            if [ $CIRCLE_BRANCH == $SOURCE_BRANCH ]; then
              git config --global user.email $GH_EMAIL
              git config --global user.name $GH_NAME

              git clone $CIRCLE_REPOSITORY_URL out

              cd out
              git checkout $TARGET_BRANCH || git checkout --orphan $TARGET_BRANCH
              git rm -rf .
              cd ..

              npm run build

              cp -a build/. out/.

              mkdir -p out/.circleci && cp -a .circleci/. out/.circleci/.
              cd out

              git add -A
              git commit -m "Automated deployment to GitHub Pages: ${CIRCLE_SHA1}" --allow-empty

              git push origin $TARGET_BRANCH
            fi

Happy building!


On a side note, this config file is intentionally missing two possible additions:

  • Put the deployment script into a separate deploy.sh script
  • Use Circle CI workflows to have a cleaner way of running the script only on the master branch.