Reviving my personal site with Zola and Github Pages

2024-10-04

Recently, something in the back of my mind reminded me that I used to have at least a minimal presence on the web beyond my GitHub profile. My personal site has been long dead - it was last seen on the Internet Archive sometime in 2018, and I think it's fair to say that my professional life has had some changes since then. It's been so long that I've sort of forgotten by now, but to the best of my memory, the previous incarnation of this site was hosted on NearlyFreeSpeech. As the archive.org link suggests, though, this was not an especially sophisticated setup, and I don't have any real need to recreate it. I believe GitHub Pages actually did exist at the time, and I'm not really sure why I avoided it for so long - probably because it seemed to easy to be much fun. Anyway, at this point I decided I'd rather go for actually free rather than nearly free, and figure out a way to make my own fun on top of GitHub Pages.

Goals

My motivation here was pretty evenly split between personal and technical reasons. On a personal level, lately I have found myself wanting to put some words out there. I've gotten a lot of help from other folks' writing, and I've also been accumulating my own small list of topics that I think I have something to say about.

On the technical side, I had a few main goals:

  • Hands-on simple web design - I basically never get to do any UI/frontend work in my day-to-day, so I wanted to be pretty hands-on with the web design side of things. I don't really want to spend a ton of time learning complex JS frameworks, though, so I wanted to stick to relatively basic HTML/CSS, with minimal Javascript as needed.
  • Markdown-based text editing - Markdown has become the de facto standard for technical writing, and it's my personal preference as well. The ecosystem for Markdown editing is great; I've started using Obsidian for personal notes, but there are a few other editors out there that are intriguing. Having the site be Markdown-based will (hopefully) help me write more frequently.
  • Easy development and deployment workflow - This one is pretty much a given, but I don't want the development workflow to be too painful. This means good support for both local development and deployment workflows.

Rewrite it in Rust (ish)

Some time in college, I messed around with setting up a little Jekyll-based blog, also hosted on NFSN. I've never really been a big Ruby enjoyer, so Jekyll didn't really hold my interest and I don't think I ever wrote a single non-test post. I did take a cursory look at the repo, and what I found was not especially inspiring. There's some nasty openssl stuff in the TravisCI config there, and some sort of ancient version of a GitHub Actions workflow. That stuff is probably better left in the past.

My day jobs over the last few years have had a disappointingly small amount of Rust involved, so I started out by searching for a Rust-based static site generator. The implementation language doesn't really make that much of a difference, but if I end up wanting to dive in and contribute, getting the chance to write some Rust would be nice. Also, I'm very far from a Javascript expert, so I'm happy to start with something that makes very few assumptions about the frontend implementation. I've seen mdBook used for a lot of open-source documentation, but that isn't really the style I was going for. The remaining two mature candidates seem to be Zola and Cobalt. To be honest, I didn't look too closely at Cobalt - one sentence in the Zola README sold me pretty much immediately:

This tool and its template engine tera were born from an intense dislike of the (insane) Golang template engine and therefore of Hugo that I was using before for 6+ sites.

I have lost enough time fighting with Golang templates to deeply appreciate this sentiment.

Zola also meets all my requirements:

  • Its template system is straightforward yet flexible, and it doesn't come with any particular notions about frontend technologies out of the box (aside from SASS support).
  • It's Markdown based, with a pretty powerful shortcode system as well.
  • Local development is easy with zola serve, and there seems to be some existing GHA community support.

Setting up a development workflow

In order to support deploying branches to test changes live on other devices, I decided to set up a two-repo system[1]:

  • A development repo, with two primary workflows:
    • When a PR is opened, build a copy of the site based on the PR, and push it to the development GitHub Pages deployment.
    • When a PR is merged, mirror the commit to the main repo.
  • A "prod" repo, with just one workflow:
    • On push to main, build and deploy to GitHub Pages.

The dev PR workflow seems like a natural starting point, so let's build that one first. All it needs to do is:

  1. Install Zola, so we can build the site.
  2. Build the site with a custom prefix - we need to configure not only the development Pages deployment root, but also the special PR directory structure.
  3. Push the built site!

Normally, I prefer to use the GHA-based Pages deployment route. However, the development site might need to support multiple in-flight PRs being published at once. In this case, it makes more sense to just push each PR to its own directory on a shared gh-pages branch and use the older branch-based publishing workflow. Tying those all together, we get a workflow that looks like the following:

on:
  pull_request:

permissions:
  contents: write

name: Build and deploy staging site
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4
      - name: Install Zola
        uses: taiki-e/install-action@v2
        with:
          tool: zola@0.19.2
      - name: Build
        run: zola build --base-url https://csssuf.github.io/<repo-scrubbed>/pr-${{ github.event.pull_request.number }}
      - name: Archive
        uses: actions/upload-artifact@v4
        with:
          name: pubsite-${{ github.sha }}
          path: public

  push:
    runs-on: ubuntu-latest
    needs: [build]
    steps:
      - name: Checkout gh-pages
        uses: actions/checkout@v4
        with:
          ref: gh-pages
      - name: Fetch artifact
        uses: actions/download-artifact@v4
        with:
          name: pubsite-${{ github.sha }}
          path: pr-${{ github.event.pull_request.number }}
      - name: Commit and push
        run: |
          git config --global user.name "Github Actions"
          git config --global user.email "noreply@csssuf.net"
          git add pr-${{ github.event.pull_request.number }}
          git commit --allow-empty -m "Add/update PR ${{ github.event.pull_request.number }}"
          git push

That should get us what we're looking for. And lo and behold, we have per-PR site builds: GitHub actions workflow run showing the development site being built and published. Development site showing the PR build.

Exactly what we wanted!

Pushing to prod

We can also crib about 90% of this workflow for the main site build. The only differences are that:

  • The main site build should just accept the default base URL.
  • We don't need to push to a branch for the main site, since we'll use the GHA publishing workflow for Pages. That means we also won't need to split the workflow into two jobs.

And indeed, the main site workflow is very simple:

on:
  push:
    branches:
      - main

permissions:
  contents: write
  pages: write
  id-token: write

name: Build and deploy site
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4
      - name: Install Zola
        uses: taiki-e/install-action@v2
        with:
          tool: zola@0.19.2
      - name: Build
        run: zola build
      - name: Archive
        uses: actions/upload-pages-artifact@v3
        with:
          path: public
      - name: Deploy
        uses: actions/deploy-pages@v4

That part's easy. All we have left to do to connect the two halves is our workflow to mirror commits to main in the development repo to main in the primary repo. To do this, we'll need to give the development repo access to push to the primary repo. I've done this with a fine-grained Personal Access Token, which lets us scope the token to just the permissions and repos involved. After creating a token associated with both repositories that has read-only metadata and read/write contents permissions, I stored that token as an Actions secret in the development repo. Then, we can assemble the pieces to check out both repos, copy the contents, and push to the primary repo:

on:
  push:
    branches:
      - main

name: Mirror to main repo
jobs:
  push:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout this repo
        uses: actions/checkout@v4
        with:
          path: development
      - name: Checkout main repo
        uses: actions/checkout@v4
        with:
          token: ${{ secrets.PUBSITE_REPO_GITHUB_TOKEN }}
          repository: csssuf/pubsite
          path: main
      - name: Copy, commit and push
        run: |
          cp -rt main development/{config.toml,content,sass,static,templates,themes}
          cd main
          git config --global user.name "Github Actions"
          git config --global user.email "noreply@csssuf.net"
          git add .
          git commit --allow-empty -m "dev: ${{ github.sha }}"
          git push

After merging to main, we can see that the site is pushed to the primary repo and deployed for real - otherwise, you wouldn't be able to read this post!

Clean up after yourself

We're able to test out changes before merging, and deploy the prod site upon merging PRs, but now we have one more problem to solve - all those old PRs are still included in the development site. There's no reason to keep those around, so we should clean them up when we merge a PR to main. Fortunately, GitHub lets us filter pull_request events by which action triggered them. The one we're interested in here is closed, aptly enough. Since this is still a pull_request event, we have the PR number accessible in the event context, which lets us know which directory to clean up. Given that, the corresponding workflow is pretty straightforward:

on:
  pull_request:
    types:
      - closed

permissions:
  contents: write

name: Clean up merged PR preview
jobs:
  cleanup:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout gh-pages
        uses: actions/checkout@v4
        with:
          ref: gh-pages
      - name: Commit and push
        run: |
          git config --global user.name "Github Actions"
          git config --global user.email "noreply@csssuf.net"
          git rm -rf pr-${{ github.event.pull_request.number }}
          git commit -m "Clean up PR ${{ github.event.pull_request.number }}"
          git push

Just to confirm it's working, let's look at our gh-pages branch on the development repo: gh-pages branch with only one PR directory

Looks good! We only see one directory, which corresponds to the currently-open PR.

The future

I didn't go into too much depth on the actual Zola layout and implementation here, but it's honestly not as interesting - it's some pretty basic HTML templating and a tiny bit of CSS. However, it's definitely helped satisfy the urge I had to build something tangible working with technologies I rarely get to touch.

It took me about two months to write this post after doing the initial setup here. I spent some of that time tweaking the look of the site, but if I'm being honest, most if it was spent finding my voice for this post. While I'm still figuring out my writing workflow, I've got a few other ideas on the backburner. Hopefully the motivation to explore them means that it won't be another two months before I write the next post!


  1. Using two repos isn't necessarily ideal here, but I wanted to keep the two Pages deployments separate in order to keep the PR builds relatively private.