Fixing the GitHub Actions
My GitHub Actions had been failing for months without me noticing, so I fixed them, dropped a dependency, and finally got proper cache headers. Normally every week or so, GitHub sends me an email saying that there’s a new version of tabi (the theme on this site). That’s an action running on a schedule, opening a pull request whenever there’s an update. I hadn’t received those emails in a while. Partially that’s down to Apple’s Mail app being an unreliable mess with its rules[1], but I could also have been that there simply weren’t any updates. When I randomly logged into GitHub, and noticed a pile of pending pull requests. And in the actions, every single run was failing. It was about time to dig a bit further. The workflows didn’t run, because the actions they use depended on versions of Node that GitHub no longer supports[2]. Apparently it hadn’t supported them for many months already, but I didn’t check it for a while. Bumping It was a bit strange to me on how Github would expect you to find out about these things. It’s clearly one of those things that I would spend time thinking about as a product manager, whenever you introduce anything you think about what to do with migrations and informing users. GitHub deprecates a dependency that I suppose loads of actions depend on, including those in my repository. And the only way of knowing is looking at the actions. There’s a news article about it, sure, but you would expect that with a major deprecation like that, you’d get an email of some sort: Hey, your actions keep failing, and it’s because they depend on a version we no longer support. Here’s the news article. Maybe it’s my settings or preferences somewhere. Either way, nothing proactively reached out to me, so I missed it, and I was months behind on maintenance (clearly, it’s not my dayjob 😂). The deploy used a third-party action, But Claude pointed out that the action runs on an Ubuntu runner, which already has the AWS CLI installed. So the dependency isn’t strictly necessary at all. I tend to prefer not having a dependency, so that was an easy decision: drop it, and just use the CLI. I might have leaned differently a couple of years ago, if I’d had to do all of this manually myself. Hoping the third-party action would make it simpler. Now, with AI assisting in these kinds of changes, it’s an easy choice to just throw it over to Claude and let it figure it out[3]. It also means there’s no Node runtime left that can be deprecated, so less prone to breaking again in the future I suppose. The deploy action set the same Now that the deploy is being rebuilt using the CLI anyway, it was a good opportunity to finally implement it. Something I wanted years ago, when I first built this site. The upload now happens in two passes: This makes the website ridiculously fast to navigate. Going back to any page you visited before is literally instant, and would probably work even without an internet connection. Seriously. Advanced stuff like forwarding some emails basically always fails (100% if they include attachments). And even basic things like moving some emails to specific folders will depend on however the stars are aligned at the time of processing. ↩ Really, this is a great argument to just use a compiled language for everything. Having to maintain compatability with versioned runtimes is a chore we can happily do without. ↩ In this case I’ve used the AWS CLI plenty of times myself, so I would have probably figured it out, but having Claude do it was significantly easier. ↩Deprecated Node versions
actions/checkout from v4 to v7, and Zola to the latest version in both workflows, got things going again (although, that did break my website, but at least the pipeline ran).Replacing the S3 deploy action
reggionick/s3-deploy, with its own Node runtime. My first instinct was to look for a newer version of it. I simply don’t know much about actions, and assumed I needed it.Better cache headers
Cache-Control header for everything: a week. These Expires headers were something I had on my other websites where I was using Hugo. I wanted to set them up when this site went live, but I couldn’t figure out how with Zola and the GitHub deploy action. I think the documentation I checked at the time only showed a generic Cache-Control, nothing that could be defined per filetype.robots.txt) keeps the same URL across deploys, that’s cached for a week.#!/usr/bin/env bash
# Upload public/ to S3 and invalidate CloudFront, with the AWS CLI (preinstalled
# on GitHub's runners) instead of a third-party action, so there is no Node
# runtime to be deprecated. Run by .github/workflows/publish.yml after the build
# and pre-compress steps; needs BUCKET and DISTRIBUTION_ID, plus the usual AWS
# credentials and region.
set -euo pipefail
cd "$(dirname "$0")/.."
# Assets are either hash-busted by Zola (CSS/JS) or get a new filename when
# replaced, so they can be cached for a year. Everything else (HTML, feeds,
# robots.txt, the feed stylesheet…) keeps the same URL across deploys and
# gets a week, matched by whatever the first pass did not claim.
ASSETS=(js css svg ttf woff woff2 pdf png jpg jpeg JPG JPEG gif ico webp avif)
include=() exclude=()
for ext in "${ASSETS[@]}"; do
include+=(--include "*.$ext")
exclude+=(--exclude "*.$ext")
done
# Sync public/ (or one of its compressed copies) to the bucket, with
# both cache lifetimes; extra arguments are passed to both passes, last,
# because later --include/--exclude filters win.
# --exclude '*.DS_Store' keeps macOS junk out if public/ was built locally.
sync() {
local src="$1" dest="$2"; shift 2
aws s3 sync "$src" "$dest" --delete --no-progress \
--exclude '*' "${include[@]}" \
--cache-control 'max-age=31536000, public' "$@"
aws s3 sync "$src" "$dest" --delete --no-progress \
"${exclude[@]}" --exclude '*.DS_Store' \
--cache-control 'max-age=604800, public' "$@"
}
# Compressed copies first, so the function never points at a copy
# that isn't there yet. Excluded paths are also kept out of --delete,
# so syncing the originals leaves _br/ and _gz/ alone, and syncing the
# copies leaves the uncompressed ones (see Pre-compress) alone.
for enc in br gz; do
if [ "$enc" = br ]; then encoding=br; else encoding=gzip; fi
skip=()
while IFS= read -r f; do skip+=(--exclude "$f"); done < "uncompressed-$enc"
sync "public/_$enc/" "s3://$BUCKET/_$enc" --content-encoding "$encoding" "${skip[@]}"
while IFS= read -r f; do
cache='max-age=604800, public'
for ext in "${ASSETS[@]}"; do
if [[ "$f" == *."$ext" ]]; then cache='max-age=31536000, public'; fi
done
aws s3 cp "public/_$enc/$f" "s3://$BUCKET/_$enc/$f" --no-progress --cache-control "$cache"
done < "uncompressed-$enc"
done
sync public/ "s3://$BUCKET" --exclude '_br/*' --exclude '_gz/*'
aws cloudfront create-invalidation --distribution-id "$DISTRIBUTION_ID" --paths "/*"