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:
- They rebuild every morning, picking up any future-dated posts whose date has rolled around.
- They rebuild instantly when I push to
main. - They notify IndexNow about every URL in their sitemap on every deploy.
- They ship minified HTML to the edge.
- They live on a known, recent Ruby and Node, so updating any one site is a matter of bumping a version string.
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.