Updated October 2026: the workflows described here were consolidated into four.

A static site has no servers to deploy to, so it is easy to assume it needs no pipeline. This site disagrees: the build needs Ruby and Python, the output is post-processed, the Markdown has to pass a formatter, and a broken link is the most common regression a content site ships. Four workflows in .github/workflows/ handle that now, and three of them run the same production build, defined once as a composite action in .github/actions/build-site. The table below lists them; the rest of this post reads the six workflows they replaced, as they were when it was written, then adds the general practices I would apply to any static-site pipeline.

The workflows at a glance

Workflow (file) Trigger What it does
CI (ci.yml) every pull request; manual dispatch Format: every formatter in .pre-commit-config.yaml in check mode, then the gallery, image, search and math tests. Build and check: the shared production build, then internal links, the page-weight budget and search relevance
Deploy (deploy.yml) push to main, path-filtered; manual dispatch the shared production build, published with actions/deploy-pages; when a gallery CDN is set, the gallery photos go to Cloudflare first
External links (links.yml) weekly schedule; manual dispatch lychee over the Markdown sources of posts, pages, projects and news, with a seven-day cache
Accessibility (axe) (axe.yml) manual dispatch only the shared production build, served locally, then axe against one page
Which event starts which workflow in this repository EVENTS WORKFLOWS (.github/workflows/) push → main paths: assets/**, *.md, *.liquid, *.yml, … pull_request → main same path filters workflow_dispatch manual run from the Actions tab workflow_run "Deploy site" completed successfully repository_dispatch type: prettier-failed-on-pr Prettier code formatter prettier.yml — check, diff artifact Check for broken links broken-links.yml — lychee on sources Deploy site deploy.yml — build, purge, publish Axe accessibility testing axe.yml — manual only Check for broken links on site broken-links-site.yml — lychee --offline on _site Comment on pull request prettier-comment-on-pr.yml Blue: workflows chained from other workflows. Deploy site finishing fires workflow_run; a failed prettier check on a PR fires repository_dispatch, which the comment workflow turns into a PR comment linking the HTML diff artifact.
Which event started which of the six earlier workflows. Two of them were only ever started by other workflows: the post-deploy link check and the PR comment.

The deploy workflow

The "Deploy site" workflow (.github/workflows/deploy.yml), step by step Trigger push / pull_request to main with path filters, or dispatch checkout actions/checkout@v4 setup-ruby Ruby 3.3.5 bundler-cache: true setup-python Python 3.13 cache: pip yaml-update giscus.repo := github.repository jekyll build pip install nbconvert JEKYLL_ENV=production purgecss drop unused CSS from _site/assets/css deploy github-pages-deploy-action@v4 skipped on pull_request gh-pages branch _site/ committed served by GitHub Pages Running alongside, on the same push / PR Prettier code formatter: npm ci, prettier --check, and an HTML diff artifact when the check fails Check for broken links: lychee over source .md and .html, excluding localhost and Liquid template variables After a successful run: workflow_run "Check for broken links on site" rebuilds _site and runs lychee --offline over every generated HTML file, so a broken internal link is reported against the exact deploy that shipped it.
The single job in deploy.yml. Every step before the last runs on pull requests too, so a PR gets a full build without publishing anything.

Triggers and path filters

The workflow runs on pushes and pull requests to main, but only when files that affect the output change. The filter is a list of globs with negations:

on:
  push:
    branches: [main]
    paths:
      - "assets/**"
      - "**.bib"
      - "**.html"
      - "**.liquid"
      - "**/*.md"
      - "**.yml"
      - "Gemfile"
      - "Gemfile.lock"
      - "!.github/workflows/prettier.yml"
      - "!CONTRIBUTING.md"
      - "!README.md"
  workflow_dispatch:

A change to README.md or to another workflow's YAML does not trigger a rebuild. The workflow_dispatch entry is the manual override: it lets me redeploy from the Actions tab when something outside the repository changed, such as a remote data source the build fetches.

Permissions and caching

permissions:
  contents: write

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: ruby/setup-ruby@v1
        with:
          ruby-version: "3.3.5"
          bundler-cache: true
      - uses: actions/setup-python@v5
        with:
          python-version: "3.13"
          cache: "pip"
      - uses: fjogeleit/yaml-update-action@main
        with:
          commitChange: false
          valueFile: "_config.yml"
          propertyPath: "giscus.repo"
          value: ${{ github.repository }}

contents: write is needed because the deploy step pushes the built site to a branch. It is the only permission granted; the default token has no other scopes.

Two caches keep the job short. bundler-cache: true makes setup-ruby run bundle install and cache the installed gems keyed on Gemfile.lock, so a run with unchanged dependencies restores them instead of compiling native extensions. cache: "pip" does the same for the Python packages the build needs (nbconvert, for notebooks). The yaml-update-action step rewrites one key in _config.yml at build time so that the Giscus comments widget always points at the repository the workflow runs in, without committing the change; that matters for forks.

Build, purge, publish

- name: Install and Build
  run: |
    pip3 install --upgrade nbconvert
    export JEKYLL_ENV=production
    bundle exec jekyll build
- name: Purge unused CSS
  run: |
    npm install -g purgecss
    purgecss -c purgecss.config.js
- name: Deploy
  if: github.event_name != 'pull_request'
  uses: JamesIves/github-pages-deploy-action@v4
  with:
    folder: _site

JEKYLL_ENV=production turns on the HTML minifier and JavaScript compression that the development build skips. PurgeCSS then reads every generated HTML and JavaScript file and strips selectors from the compiled stylesheets that nothing references, which is how a Bootstrap-based theme ends up shipping a fraction of Bootstrap.

The deploy step publishes by committing _site to a branch that GitHub Pages serves. The if: guard is the whole PR story: a pull request runs every step up to and including PurgeCSS, so a build error or a Liquid mistake fails the check, but nothing is published until the merge commit lands on main.

The complete deploy.yml
name: Deploy site

on:
  push:
    branches:
      - master
      - main
    paths:
      - "assets/**"
      - "**.bib"
      - "**.html"
      - "**.js"
      - "**.liquid"
      - "**/*.md"
      - "**.yml"
      - "Gemfile"
      - "Gemfile.lock"
      - "!.github/workflows/axe.yml"
      - "!.github/workflows/broken-links.yml"
      - "!.github/workflows/deploy-docker-tag.yml"
      - "!.github/workflows/deploy-image.yml"
      - "!.github/workflows/docker-slim.yml"
      - "!.github/workflows/lighthouse-badger.yml"
      - "!.github/workflows/prettier.yml"
      - "!lighthouse_results/**"
      - "!CONTRIBUTING.md"
      - "!docs/**"
      - "!README.md"
  pull_request:
    branches:
      - master
      - main
    paths:
      # same list as above
      - "assets/**"
      - "**/*.md"
      - "**.yml"
  workflow_dispatch:

permissions:
  contents: write

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4
      - name: Setup Ruby
        uses: ruby/setup-ruby@v1
        with:
          ruby-version: "3.3.5"
          bundler-cache: true
      - name: Setup Python
        uses: actions/setup-python@v5
        with:
          python-version: "3.13"
          cache: "pip"
      - name: Update _config.yml
        uses: fjogeleit/yaml-update-action@main
        with:
          commitChange: false
          valueFile: "_config.yml"
          propertyPath: "giscus.repo"
          value: ${{ github.repository }}
      - name: Install and Build
        run: |
          pip3 install --upgrade nbconvert
          export JEKYLL_ENV=production
          bundle exec jekyll build
      - name: Purge unused CSS
        run: |
          npm install -g purgecss
          purgecss -c purgecss.config.js
      - name: Deploy
        if: github.event_name != 'pull_request'
        uses: JamesIves/github-pages-deploy-action@v4
        with:
          folder: _site

Some of the negated paths refer to workflows that no longer exist in this fork (deploy-image.yml, docker-slim.yml); they are inherited from the upstream theme and are harmless, but they are a reminder that path filters deserve the same pruning as code.

Formatting with a pinned Prettier

- name: Install Prettier
  # npm ci installs the exact pinned versions from package-lock.json —
  # the unpinned "npm install prettier ..." used to pull the latest
  # release, which disagreed with the pre-commit hook's pinned 3.1.1
  # and failed CI on files that pass locally.
  run: npm ci
- name: Prettier Check
  id: prettier
  run: npx prettier . --check

The comment is the lesson. For a while CI installed whatever the latest Prettier was, while the local pre-commit hook used the version pinned in package.json. Formatting rules drift between releases, so files that passed locally failed in CI. npm ci installs exactly what package-lock.json says, and the two environments agree again.

When the check fails, the workflow does something more useful than printing a list of files: it runs prettier --write, diffs the result, renders the diff to HTML with diff2html, and uploads it as an artifact kept for seven days. On pull requests it then fires a repository_dispatch event carrying the PR number and the artifact URL, and the comment workflow turns that into a single PR comment (using a comment_tag so repeated failures update one comment instead of adding more).

Links break in two places: in the sources, where a URL in a Markdown file points at a page that moved, and in the output, where a template produces an internal path that does not exist. The repository checks both with lychee.

The source check runs on the same push and PR events as the deploy, over ./**/*.md and ./**/*.html, with a user agent set to look like curl and a list of exclusions that are worth copying into any Jekyll project:

args: >-
  --user-agent 'curl/7.54'
  --exclude-path README.md
  --exclude 'localhost'
  --exclude 'http://127.0.0.1'
  --exclude 'github.com/.*/issues$'
  --exclude '%7B%7B'
  --exclude '\{\{'
  --verbose
  --no-progress
  './**/*.md'
  './**/*.html'

The two patterns at the end exclude Liquid template variables, which look like URLs to a naive scanner once they are inside an href.

The output check is chained to the deploy with workflow_run and only runs if the deploy succeeded:

on:
  workflow_run:
    workflows: [Deploy site]
    types: [completed]

jobs:
  check-links-on-site:
    if: ${{ github.event.workflow_run.conclusion == 'success' }}

It rebuilds the site (a workflow_run job does not inherit the previous job's files) and runs lychee with --offline, which checks only internal links against the files in _site. That is the right scope for this stage: external URLs were already checked at the source level, and an offline run is fast and cannot be flaky because some third-party site is slow.

WARNING

workflow_run jobs always execute in the context of the default branch, with that branch's workflow file, even when the triggering run came from a pull request. That is a security feature (a PR cannot edit the chained workflow to steal its secrets), but it means changes to a workflow_run workflow only take effect after they are merged.

Habits worth adding

The workflows above are honest and functional. Three practices would make them more robust, and apply to any static-site pipeline.

Concurrency groups

Two pushes in quick succession start two deploy runs, and the older one can finish last and publish stale output. A concurrency group cancels the superseded run:

concurrency:
  group: deploy-${{ github.ref }}
  cancel-in-progress: true

For the deploy itself, cancel-in-progress: false is the safer setting if the publish step is not atomic; for pure checks like Prettier and lychee, cancelling is always fine.

Pin actions to a commit

uses: actions/checkout@v4 floats on a tag that the maintainer can move; uses: fjogeleit/yaml-update-action@main floats on a branch that can change on any push. Pinning to a full commit SHA makes the workflow reproducible and removes the possibility of a tag being re-pointed at malicious code. The procedure:

  1. Find the commit behind the tag you currently use: git ls-remote --tags https://github.com/actions/checkout v4 prints the SHA.
  2. Replace the reference and keep the tag as a comment so humans can still read it: uses: actions/checkout@<sha> # v4.2.2.
  3. Enable Dependabot for the github-actions ecosystem in .github/dependabot.yml, which opens pull requests that bump the SHA and the comment together.
  4. Repeat for every uses: line, starting with the ones that run with write permissions.

Least privilege by default

The deploy workflow already scopes its token to contents: write. The others rely on the repository default. Setting permissions: {} at the top of a workflow and granting only what each job needs (contents: read for a check, pull-requests: write for the commenter) means a compromised action in a check job cannot push to the repository.

Further reading