Safe-Mode Build Overlay (Building Outside GitHub Pages)
Build a Zer0-Mistakes remote-theme site in your own CI the way GitHub Pages does — start from your site, copy in only what remote_theme delivers.
Safe-Mode Build Overlay
What you’ll do: reproduce a GitHub Pages build of your remote-theme site in your own CI, so a custom pipeline (caching, link-checking, multi-site builds) renders exactly what Pages would — no more, no less.
Why this is needed
When you build outside GitHub Pages — your own GitHub Actions, GitLab CI, or a local jekyll build — nothing replicates Pages’ two constraints for you:
remote_themeonly delivers_layouts/,_includes/,_sass/, andassets/. Your_config.yml,_data/, and content stay local.- Pages runs Jekyll in safe mode, which ignores
_plugins/*.rb. The theme’s generators (search, sitemap, author pages, previews) never run on a Pages consumer build.
If your custom build doesn’t strip _plugins, you get pages locally that 404 on the real Pages site — the build and production diverge silently. The overlay recipe makes your CI build match Pages exactly. (For the files Pages does not deliver, see the Remote-Theme Consumer Checklist.)
The recipe
Four steps: start from your site → copy in only what remote_theme delivers → strip _plugins → strict build.
#!/usr/bin/env bash
set -euo pipefail
THEME_URL="${THEME_URL:-https://github.com/bamr87/zer0-mistakes.git}"
THEME_REF="${THEME_REF:-v1.20.2}" # pin a tag, not a moving branch
THEME_DIR="$(mktemp -d)"
BUILD_DIR="$(mktemp -d)"
# 1. Clone the theme into its OWN directory at a pinned ref (shallow is fine).
git clone --depth 1 --branch "$THEME_REF" "$THEME_URL" "$THEME_DIR"
# 2. Start from YOUR site — exactly what Pages checks out — then copy in only
# the four directories remote_theme delivers. --ignore-existing: your
# files win on every collision, as they do on Pages.
rsync -a --exclude '.git' ./ "$BUILD_DIR/"
for d in _layouts _includes _sass assets; do
rsync -a --ignore-existing "$THEME_DIR/$d/" "$BUILD_DIR/$d/"
done
# 3. Strip _plugins — Pages safe mode never runs them, so neither should you.
rm -rf "$BUILD_DIR/_plugins"
# 4. Strict build, exactly as Pages would (minus the plugins).
cd "$BUILD_DIR"
bundle exec jekyll build --strict_front_matter --trace
The output in $BUILD_DIR/_site is what a real Pages consumer would serve: your content and config plus the theme’s _layouts/, _includes/, _sass/ and assets/ — nothing else from the theme repo.
Why an allowlist, not a denylist
An earlier version of this recipe cloned the whole theme repo into $BUILD_DIR and copied the site on top. Everything else in the theme repo then leaked into the build: its _data/ merged with yours (the theme’s _data/navigation/docs.yml gave consumer /docs/ pages a sidebar full of theme-only links), its root README.md/AGENTS.md/CLAUDE.md became /AGENTS/ and /CLAUDE/ routes, examples/ and templates/ broke strict builds, features/README.md became /features/, and the machine-translated fr/ tree added pages the consumer does not have. A denylist (rm -rf "$BUILD_DIR/examples" …) has to grow every time the theme repo adds a top-level directory. Copying in exactly the four directories remote_theme delivers (Remote-Theme Consumer Checklist) matches Pages by construction.
Why strict?
--strict_front_matterfails the build on a malformed front matter block instead of silently skipping the page — the same failure mode you want to catch in CI rather than discover in production.
As a reusable CI step
Wrap the recipe in a function so multiple jobs can share it:
lh_overlay() { # build a remote-theme site the Pages way
local theme_ref="${1:-v1.20.2}" out="${2:-_site}"
local theme_url="${THEME_URL:-https://github.com/bamr87/zer0-mistakes.git}"
# Resolve the output dir in the CALLER's directory, before any cd: inside
# the subshell below, $PWD is already the temp dir.
local dest
case "$out" in /*) dest="$out" ;; *) dest="$PWD/$out" ;; esac
local theme work d
theme="$(mktemp -d)"; work="$(mktemp -d)"
git clone --depth 1 --branch "$theme_ref" "$theme_url" "$theme"
rsync -a --exclude '.git' ./ "$work/" # your site, as Pages sees it
for d in _layouts _includes _sass assets; do # exactly what remote_theme delivers
rsync -a --ignore-existing "$theme/$d/" "$work/$d/"
done
rm -rf "$work/_plugins" # Pages safe mode
( cd "$work" && bundle exec jekyll build --strict_front_matter -d "$dest" )
}
In GitHub Actions:
- name: Build (safe-mode overlay)
run: |
source scripts/ci/build.sh # defines lh_overlay
lh_overlay "v1.20.2" "_site"
- name: Link-check the built site
run: npx --yes linkinator _site --silent --recurse
Verifying the overlay matches Pages
After the build, sanity-check that you didn’t ship anything Pages wouldn’t:
- No plugin-only routes —
/search.json,/sitemap/,/authors/etc. should be absent unless you committed static stubs for them. Their presence locally but not on Pages is the classic divergence this recipe prevents. - No theme-repo routes —
_site/AGENTS/,_site/features/and_site/fr/must not exist (unless your own site has those pages). They come from files in the theme repo thatremote_themenever delivers. - No theme data —
$BUILD_DIR/_data/navigation/docs.ymlmust not exist unless your site ships its own; the theme’s_data/is never merged into a Pages build. - Run a link checker over
_siteand treat theme-injected 404s as build failures, so a regression surfaces in CI rather than for a visitor.
Automate the theme-repo route checks in CI:
for p in AGENTS features fr; do test ! -e "_site/$p" || { echo "leaked: /$p/"; exit 1; }; done
See also
- Remote-Theme Consumer Checklist — the
files and config to add when consuming the theme via
remote_theme. - Deployment overview — hosting options and trade-offs.