Tales From The Keyboard

Software developer. Always breaking something.

06 Jun 2020

Super Powered Blogging With Hugo and GitHub Pages and Actions

Nowadays blogging platforms are a dime a dozen. You’ve got WordPress that is still king, but it’s kind of bloated and has plenty of weird gotchas that can lead you down into rabbit holes that lead you to realize there are more than 10 levels of abstraction until you get down to calls to cURL from initiating an HTTP GET request with their API (this is a horrific story, I don’t know if I want to think about it any further than that). Sometimes, you want something lean and fast. You’re just writing a simple blog for yourself, you don’t need a fancy WYSIWYG editor and plugins galore. You could go for a headless SaaS option like Contentful, but that’s expensive and even more overkill than WordPress for your needs. Where is something simple?

Static site generators to the rescue! These handy tools are great for building out a simple blog just to share your thoughts and knowledge with the world. They ultimately end up being deployed as static files, but have plenty of flexibility and power to handle whatever your blogging requirements are. Probably. You may need an API for some things, but we can talk about that later. Static sites have another benefit though. They are incredibly cheap to deploy and easy to scale. Sometimes they are even free to deploy, as is the case with GitHub Pages. Coupled with GitHub Actions we’ll get a completely automated pipeline set up so you can write posts on GitHub itself if you wish! Static site generators are a dime a dozen, with choices between Jekyll, Gatsby.js, Middleman, 11ty, or Hugo. GitHub Pages supports Jekyll natively, but it uses Ruby and right now you are developing this on a Windows laptop and you don’t want to deal with that dumpster fire to get an environment stood up locally. So… we’re going to go with Hugo as an example.

What are these things and why do I care?

  • Hugo is a static site generator written in the Go programming language. However, little to no knowledge of Go is necessary to begin using it. Being powered by Go allows it to run easily on Windows, macOS, and Linux with no dependencies. And it’s blazing fast. It’s also particularly featured towards blogging with features like syntax highlighting and Markdown support out of the box.

  • GitHub Pages is a hosting platform for static websites powered by GitHub. It provides free SSL and supports custom domains. Best of all, it is free for all public repositories.

  • GitHub Actions is GitHub’s answer to CI/CD for the GitHub platform and is free for all public repositories. For the purposes of this article, it will be our method of handling automated deployments of our blog to GitHub Pages. CI/CD stands for continuous integration/continuous deployment. CI/CD platforms provide tooling for automating various processes that go into a software deployment pipeline, including testing, linting, and deployment. In our case, we have no logic to test and don’t care about linting, so we are only worried about the automatic deployment of code. There could be potential use cases for automated browser testing depending on how far you take this platform, but that is a topic for another post. :)

Together, these technologies allow you to build a pretty slick blogging set up for vomiting out your thoughts and dreams… for free! Utilizing the power of these tools will allow us to develop some very powerful features into our blog that normally require something more complex like WordPress or another CMS. Hugo alone takes care of things like taxonomies, page/post templating, syntax highlighting, and Markdown support. With GitHub Actions, we will also be able to build in post scheduling and other publication workflows. The sky is the limit here.

So, how do I get started?

To follow along with this article, you will need to have a few things ready to go.

Setting up your GitHub Repositories

GitHub Pages is a feature that is enabled at the repository layer. There are three types of GitHub Pages: user, organization, and project. We will be creating project pages in this article. By default, project pages use the gh-pages branch of the repository as its publishing source, but can be configured to use the master branch or the /docs folder of the master branch. User and organization pages only support the master branch and /docs folder options. When GitHub Pages is active, it will serve all of the files in the publishing source of certain MIME types. It will serve images, HTML, CSS, and JavaScript. Which is all we need for a simple blog.

First of all, you’ll need a repository for your Hugo files. We could just use the gh-pages branch of that same repository to host the built static files, but that requires that repository to be public in order to get GitHub Pages enabled on it for free. That kind of defeats the point of some of Hugo’s features like drafting and post scheduling. Sure, the post may not be on the website, but the content is sitting out in the open on your repository. Maybe you want those drafts to actually be private! To get around this, we will use two repositories. Your main repository can be a private one. Don’t worry, we’ll be needing access to GitHub Actions for this repository which comes with a generous 2,000 minutes per month allotment for private repositories on Free tier GitHub accounts. Public repositories have access to GitHub Actions completely for free. It’s up to you!

The second repository has to be public for free GitHub Pages. Private repositories do not get access to GitHub Pages until you are at least on the Pro plan (which is $4/mo). This repository will essentially act as our build target.

For the purposes of demonstration, I will use https://github.com/cameronbroe/hugo-gh-actions-main as my main repository and https://github.com/cameronbroe/hugo-gh-actions-built as my build repository. The main repository is taken from the Hugo quick start example and the built repository is the GitHub Pages host.

So now you’ve got two repositories? Great, let’s go on and initialize our build repository. We will need it to have something in it in order to link it to our main repository.

These commands will create a repository, add an empty README file, and push it to the GitHub build repository with a gh-pages branch.

mkdir hugo-gh-actions-built
cd hugo-gh-actions-built
git init
git checkout -b gh-pages
touch README
git add README
git commit -m "init build repo"
git remote add origin https://github.com/cameronbroe/hugo-gh-actions-built
git push -u origin gh-pages

Linking these two repositories together

We will make use of a neat feature of Git called submodules. Basically, a submodule allows you to specify that a directory of a Git repository should actually be another Git repository. This submodule is completely separate, but is contained in-tree with the parent Git repository. By default, Hugo builds its output to the public/ directory. So, we’ll take advantage of this.

The command to do this is as follows:

git submodule add https://github.com/cameronbroe/hugo-gh-actions-built public

IMPORTANT: Use an HTTPS link, not an SSH link. This will simplify handling the submodule inside of GitHub Actions.

Setting up GitHub Actions for deployment

GitHub Actions lets you execute workflows when designated events happen with your repository. This can be any event on GitHub such as a push event, a pull request is opened, or an issue is closed. You can also execute these workflows as a cronjob as well. A workflow is simply a definition of a set of actions that should run on one of their virtual machines. They provide Linux, macOS, and Windows runners currently. These actions can basically be anything you want. We will be using a GitHub Action to run a build on our Hugo repository. These workflows are defined in YAML files in the .github/workflows/ directory of your repository. Let’s create one now for our Build & Deploy workflow.

Create a file at .github/workflows/main.yml.

Go on and paste the following block in as the contents of the file:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
name: Hugo Build and Deploy

on:
  push:
    branches: [ master ]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
    - uses: actions/[email protected]
      with:
        token: ${{ secrets.AUTHENTICATED_PAT }}
        submodules: true

    - name: Fix detached head on public submodule
      run: |
        cd public/
        git checkout gh-pages

    - name: Configure Git name
      run: git config --global user.name "Cameron Roe"

    - name: Configure Git email
      run: git config --global user.email "[email protected]" foo

    - name: Download Hugo
      run: wget https://github.com/gohugoio/hugo/releases/download/v0.71.0/hugo_0.71.0_Linux-64bit.tar.gz

    - name: Extract Hugo
      run: tar -xvf hugo_0.71.0_Linux-64bit.tar.gz

    - name: Run Hugo build
      run: ./hugo

    - name: Push Hugo build in public submodule
      run: |
        cd public/
        git add .
        git commit -am "build for ${{ github.sha }}" || echo "Website build is identical, nothing to commit"
        git push -u origin gh-pages

Whoa…there’s a decent bit there. Let’s go over it in chunks.

Configuring the GitHub Action workflow

First, we need to specify what we want to call our workflow and when we want it to be executed. There are numerous events that you can have a workflow execute from, but for now we’ll just start with every push to the master branch. This will cause the workflow to trigger everytime we push our code to the repository.

1
2
3
4
name: Hugo Build and Deploy
on:
  push:
    branches: [ master ]

Next, we need to specify a job to execute as part of the workflow. Multiple of these can be defined, but we only need one for now. We also will go on and specify which virtual environment we want to use. For our purposes, ubuntu-latest is a great option.

 7
 8
 9
10
jobs:
  build:
    runs-on: ubuntu-latest
    steps:

Getting the repository onto your runner

Then, we start to define what steps we want to do in our workflow. Our first step will be to utilize an existing action, actions/[email protected], to actually pull down the repo. We also pass in the argument submodules: true to the checkout action to tell it to pull down our deployment target submodule. By default, the Git client invoked by the checkouts action is authenticated with your account only with scope for the repository running the action. Since we will ultimately be performing write actions to another repository, you will need to specify an alternative Personal Access Token to use authenticate the Git client with. The Git client is not cleaned up until the end of the job, so all further commands will be authenticated with this Personal Access Token. GitHub Actions supports injecting encrypted secrets into your workflows. In order to set this up refer to their documentation here: https://help.github.com/en/actions/configuring-and-managing-workflows/creating-and-storing-encrypted-secrets. I have called mine AUTHENTICATED_PAT.

11
12
13
14
    - uses: actions/[email protected]
      with:
        token: ${{ secrets.AUTHENTICATED_PAT }}
        submodules: true

Because of the way the actions/checkout action handles the clone and checkout, the repository and its submodules are left on a detached HEAD. It will only grab the latest commit of the primary branch by default. This is fine for our purpose, but the state of the repository is not acceptable for being committed to and pushed, which we will need to do for our deployment target. In our case, the primary branch is going to be gh-pages.

So, our next step will be to move into our deployment target’s repository, and run a git checkout to put it on the gh-pages branch. This will correct the detached HEAD and put the repository is a committable and pushable state. We also will need to configure a name and email with the git CLI as that is a requirement for committing (so it knows who to tag the commit is from).

16
17
18
19
20
21
22
23
24
25
    - name: Fix detached head on public submodule
      run: |
        cd public/
        git checkout gh-pages

    - name: Configure Git name
      run: git config --global user.name "Cameron Roe"

    - name: Configure Git email
      run: git config --global user.email "[email protected]"

After that, we need to download the Hugo toolchain so we can run our build. The project provides releases through GitHub Releases as tarballs for Windows, Mac, and Linux. We have chosen to use Ubuntu, so we will need to grab the Linux tarball. We can use wget to download it and then extract the tarball in the same directory.

Downloading and running Hugo

27
28
29
30
31
    - name: Download Hugo
      run: wget https://github.com/gohugoio/hugo/releases/download/v0.71.0/hugo_0.71.0_Linux-64bit.tar.gz

    - name: Extract Hugo
      run: tar -xvf hugo_0.71.0_Linux-64bit.tar.gz

One of my favorite things about the Go programming language is that the provided toolchain compiles to single, statically-linked binaries by default. That means a Go program can be distributed everywhere by just giving people the right binary for their platform. No need to install any runtime dependencies or libraries. No convoluted set-up processes. Just a binary you can run. So, we don’t need to worry about installing any dependencies or anything before running the Hugo toolchain. By default, when you run the hugo command, it will build your website into the public/ directory of your site. Since that is what we ultimately want, we can just simply call the Hugo binary we downloaded.

33
34
    - name: Run Hugo build
      run: ./hugo

But what about something other than Hugo?

Technically, this pipeline could be used for something other than Hugo. I just like Hugo because it’s small, simple, and fast. You could use whatever static site generator you want. To do that, you would need to adjust this workflow to instead install your static site generator and run the necessary build command. The virtual machines come with several environments and tools pre-installed, so you may be an npm install or gem install away from using your static site generator of choice.

Deploying the built website

Now, we will handle our deployment. All GitHub Pages needs is for a push to the set branch on the repository that has it enabled. So we can just move into our public/ directory, and commit and push. We can prevent failing builds on commits that have no changes to the site by using an OR operator on the commit to ensure the line does not error out and throw a non-zero status code (which a git commit command on an unchanged repository will do).

36
37
38
39
40
41
    - name: Push Hugo build in public submodule
      run: |
        cd public/
        git add .
        git commit -am "build for ${{ github.sha }}" || echo "Website build is identical, nothing to commit"
        git push -u origin gh-pages

Seeing it in action

One last change before pushing this up. Hugo needs to have a base URL for the website defined in its configuration in order to generate links correctly. For our examples, we will be using a baseURL value of https://[your-github-username].github.io/[your-build-repository-name]/, because we are working with project GitHub Pages. If you plan to use a custom domain, then use that domain for the baseURL value.

This can be set by putting the following line into your config.toml

baseURL = "https://[your-github-username].github.io/[your-build-repository-name]/"

Go on and commit that workflow file and the configuration change to your main repository and push it up.

git add .github/workflows/main.yml
git commit -m "Adding GitHub Actions workflow for build"
git push -u origin master

GitHub Actions should pick it up automatically and execute your build. In my testing, builds take only about 20-30s to complete, so your website should be up within a minute or two.

Now, you should have a built blog deployed to GitHub Pages with SSL. It can be accessed at https://[your-github-username].github.io/[your-build-repository-name]. You can see my example here: https://cameronbroe.github.io/hugo-gh-actions-built/

Taking it a step further

Hugo supports specifying a publishDate in the frontmatter, where a publishDate in the future from the time of the build will not be part of the final build. This is their method of supporting scheduled posts. We can leverage our build pipeline we have to trigger builds every day, which will automatically publish any scheduled posts! It’s not extremely precise (as GH Actions can only run a build every 5 minutes), but it should do for a lone blogger like yourself. I would suggest running builds at most once an hour and only publish on the hour, as this will keep GitHub Actions minutes usage (remember a private repository only gets 2,000 for free) in check. So, how do we tell GitHub that we want our workflow executed once every hour?

Let’s go back to our events that we have defined we want this workflow to execute on.

1
2
3
on:
  push:
    branches: [ master ]

GitHub supports a large number of different events that can trigger a workflow. Convienently, they have a schedule event! And it simply takes a POSIX crontab entry for input.

So, change your on object to include a schedule event with a crontab to execute every hour:

1
2
3
4
5
on:
  push:
    branches: [ master ]
  schedule:
    - cron: '0 * * * *'

Note that the crontab is in quotes, YAML considers an asterisk to be a special character which requires them to be in quotes.

And to recap what we have just done

We have looked at what GitHub Pages is and how to deploy a website using the platform. We have leveraged the power of Git submodules to separate unpublished content from published content, providing a sort of rudimentary environment separation to keep your drafts separate from your public content. And we have discussed how to utilize GitHub Actions to automate builds and deployments. This enables a flexible writing environment where you can publish articles from anywhere on any device. Whether you want to write using GitHub’s built-in web editor, a web-based IDE like GitHub Codespaces, or your editor of choice in a more standard environment, you have the ability to publish your content quickly and easily, with support for scheduled publication dates. And the best part is none of this costs a dime. Now go write something for us to read!

Also, big shout to my friends Daniel and Harold for some great edits and feedback on this post before publication. This would have been a mess without your help. :D