A Shared GitHub Actions Workflow for My Static Sites

This is part 2 of a short series on modernizing my static sites and getting them ready for the agentic future. Part 1 was the Jekyll-to-Bridgetown migration; this one is about how those sites actually deploy now, and the small handful of builders that took GitHub Pages from “publish a folder” to something that feels closer to a real release pipeline.

Fair warning up front: this isn’t a clever trick. It’s the deploy workflow I copy-paste across all four of my static sites — rickychilcott.com, Rakefire, the Causey marketing site, and get.stokedhq.com — and the value isn’t in any one piece. It’s in the boring fact that all four sites now do the same handful of useful things automatically, and I never have to think about any of it.

The shape of it

The whole workflow lives in .github/workflows/pages.yml. It runs on push to main, on a daily cron, or when I trigger it manually. The build job sets up Ruby and Node, installs the pagefind binary, runs bin/bridgetown deploy with BRIDGETOWN_ENV=production, and uploads the output as a Pages artifact. The deploy job picks up the artifact and ships it. Nothing exotic.

name: Deploy to GitHub Pages

on:
  push:
    branches: [main, master]
  schedule:
    - cron: "0 6 * * *"  # Rebuild daily at 6am UTC
  workflow_dispatch:

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

concurrency:
  group: "pages"
  cancel-in-progress: false

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: ruby/setup-ruby@v1
        with:
          ruby-version: "4.0.3"
          bundler-cache: true

      - uses: actions/setup-node@v4
        with:
          node-version: "24"
          cache: "npm"

      - name: Install npm dependencies
        run: npm ci

      - name: Install pagefind binary
        run: |
          curl -sL https://github.com/CloudCannon/pagefind/releases/download/v1.4.0/pagefind-v1.4.0-x86_64-unknown-linux-musl.tar.gz | tar xz
          sudo mv pagefind /usr/local/bin/

      - name: Setup Pages
        id: pages
        uses: actions/configure-pages@v5

      - name: Build site
        run: bin/bridgetown deploy
        env:
          BRIDGETOWN_ENV: production

      - name: Upload artifact
        uses: actions/upload-pages-artifact@v5
        with:
          path: output

  deploy:
    environment:
      name: github-pages
      url: ${{ steps.deployment.outputs.page_url }}
    runs-on: ubuntu-latest
    needs: build
    steps:
      - name: Deploy to GitHub Pages
        id: deployment
        uses: actions/deploy-pages@v4

A few specific decisions are worth pulling out, because they’re the parts that earn their keep over time.

Why the daily cron

The single most useful line in that file, for me, is cron: "0 6 * * *". A daily rebuild at 6am UTC.

The reason is mundane but important: I write blog posts in Obsidian, in a vault that lives outside any of my site repos. Bridgetown’s obsidian_local_sync watches that vault and pulls content into the right place at build time. When I write a post and future-date it — which I do all the time, because I’d rather write three posts in a sprint and stagger their publish dates than write one a day on demand — I want the site to publish that post on the date in the front matter, without me having to log in and trigger anything.

The daily cron does exactly that. Every day at 6am UTC, every site rebuilds. Anything whose published date has rolled into the past since yesterday’s build appears. I push to main when I want an immediate deploy; otherwise the cron handles it.

There’s a quiet second benefit, too: any drift in dependencies or upstream content gets caught regularly rather than the next time I happen to push. If a build breaks because of something I didn’t change, I find out the next morning, not three weeks later when I finally have a typo to fix.

IndexNow, automatic on every deploy

The next piece is the IndexNow ping. IndexNow is a simple protocol — basically “here’s a list of URLs that just changed, please reindex them” — supported by Bing, Yandex, and a handful of others. Google doesn’t honor it directly, but enough of the rest of the web does that it’s worth wiring up if you’re already publishing a sitemap.

I use a small Bridgetown builder for this:

class Builders::Indexnow < SiteBuilder
  INDEXNOW_KEY = "8ebabf88-616e-4868-8096-59c9abe36a1a"
  INDEXNOW_API = "https://api.indexnow.org/indexnow"
  SITE_HOST = "https://www.rickychilcott.com"

  def build
    hook :site, :post_write do
      next unless should_run?

      urls = collect_urls_from_sitemap
      submit_urls(urls) if urls.any?
    end
  end

  private

  def should_run?
    return true if ENV["INDEXNOW"] == "true"
    return true if Bridgetown.environment == "production"
    false
  end

  def collect_urls_from_sitemap
    sitemap_path = site.in_dest_dir("sitemap.xml")
    return [] unless File.exist?(sitemap_path)

    doc = REXML::Document.new(File.read(sitemap_path))
    doc.elements.collect("urlset/url/loc") { |el| el.text }
  end

  def submit_urls(urls)
    body = {
      host: URI(SITE_HOST).host,
      key: INDEXNOW_KEY,
      keyLocation: "#{SITE_HOST}/#{INDEXNOW_KEY}.txt",
      urlList: urls
    }
    # ...standard Net::HTTP POST to INDEXNOW_API with JSON body
  end
end

The key file lives at src/{INDEXNOW_KEY}.txt and contains the same key value — that’s how IndexNow verifies you actually own the host you’re submitting URLs for. I generated the key once with uuidgen and committed both ends.

The builder runs on post_write, only in production builds, after the sitemap has already been generated. It reads the URL list straight out of sitemap.xml and POSTs the whole batch to the IndexNow endpoint. Logs go to the build output. If the API is down, it fails loudly but doesn’t break the deploy — that’s rescue => e doing its job.

This is the kind of thing I’d never bother with as a shell script. As a 50-line Bridgetown builder, it took about ten minutes and now runs forever.

HTML minification on the way out

Last builder in the deploy path is the HTML minifier, which I shared in the previous post but is worth showing here in context:

class Builders::HTMLMinifier < SiteBuilder
  def build
    hook :site, :post_write do
      next if config[:watch]

      compressor = HtmlCompressor::Compressor.new(
        remove_comments: true,
        remove_multi_spaces: true,
        remove_intertag_spaces: false,
        preserve_line_breaks: false
      )

      Dir.glob(File.join(site.dest, "**", "*.html")).each do |file|
        File.write(file, compressor.compress(File.read(file)))
      end
    end
  end
end

The savings per page aren’t dramatic — a few percent on most pages, more on long ones — but the compounding wins matter. Faster initial paint, smaller pages over the wire to lower-bandwidth clients, less CDN cache to push around. And, since GitHub Pages charges nothing and asks for nothing, the only cost is a few seconds at the end of the build.

The next if config[:watch] line is important. In dev I want fast feedback and readable HTML; in production I want every byte squeezed. One conditional, two modes, no second build script.

Why this isn’t a “shared workflow”

Here’s where I should be honest about what this is and isn’t.

GitHub Actions has a real shared-workflow primitive — workflow_call, reusable composite actions, the whole bit. I’m not using it. The workflow I just walked through lives in each of my four site repos as a copy of the same file, and when I change one I copy the change to the others by hand.

There are a few reasons I haven’t extracted it. Four sites isn’t enough to justify the cost of a shared action — I’d have to publish it somewhere, version it, write tests against it, document the inputs. The differences between sites are real, even if small: one of them needs an API2PDF_API_KEY for resume PDF generation, another needs a GH_PAT to pull in cross-repo content, another doesn’t need any secrets at all. Pushing those differences through a generic interface adds enough complexity that I’d rather just have the file in front of me.

Mostly, though, the deploy workflow isn’t really the locus of complexity. The interesting code lives in the Bridgetown builders, where it can be tested locally with bin/bridgetown build and where I’d write it anyway. The workflow is just the harness. Keeping it as a copy-paste artifact means I can also tweak it per-site when I need to (different cron times, different build environments) without inventing yet another configuration layer.

If I get to ten sites this calculus changes. At four, the boring approach wins.

What this gets you

The result is that all four of my sites do the same things, automatically, every day:

None of that is impressive in isolation. Together, it means I can stop thinking about the deploy story for any of these sites and spend my time on the things that actually matter — like the agent-readiness work I’m finally going to get to in tomorrow’s post.

What’s next

Tomorrow’s post is the one I’ve been quietly the most excited about: making each of these sites legible to AI agents, not just to browsers. Markdown twins of every page. A /.well-known/agent-skills/ directory you can point an agent at. An llms.txt. Content-Signal headers in robots.txt. The whole emerging stack of “your site, but for agents” — and the parts I still need to wire up at the Cloudflare edge to make it all work end-to-end.

It’s the most fun I’ve had on a static site in years.


Part 3 of this series — making static sites agent-ready — is up tomorrow.