The Problem
My Quarto blog has 145+ posts. Every time I push a change, GitHub Actions re-renders the entire site, which takes:
- 5-6 minutes
- Re-executes all Jupyter notebooks
- Hits memory limits on large notebooks
- Often fails on problematic files
This is wasteful. If I edit 1 post, why rebuild 145?
The Solution: Incremental Builds
The key insight: Quarto can render individual files, not just the entire project. We can:
- Detect which files changed since the last successful build
- Render only those files
- Merge with cached
_sitedirectory - Push to gh-pages
This means: 1 file changed = 1 file rendered
Implementation
Here’s the improved GitHub Actions workflow:
on:
workflow_dispatch:
push:
branches: master
name: Quarto Publish
jobs:
build-deploy:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Check out repository
uses: actions/checkout@v4
with:
fetch-depth: 0 # Full history for detecting changes
- name: Set up Quarto
uses: quarto-dev/quarto-actions/setup@v2
- name: Cache rendered site
uses: actions/cache@v4
with:
path: _site
key: quarto-rendered-${{ github.run_number }}
restore-keys: |
quarto-rendered-
- name: Detect changed files
id: changed
run: |
# Get the previous commit to see what changed in this push
PREV_COMMIT=$(git rev-parse HEAD~1)
echo "Previous commit: $PREV_COMMIT"
# Get list of changed post files
CHANGED=$(git diff --name-only $PREV_COMMIT HEAD -- posts/*.qmd posts/*.md posts/*.ipynb 2>/dev/null || true)
if [ -z "$CHANGED" ]; then
echo "No changes detected in posts"
echo "changed=false" >> $GITHUB_OUTPUT
echo "files=" >> $GITHUB_OUTPUT
else
echo "changed=true" >> $GITHUB_OUTPUT
FILES=$(echo "$CHANGED" | tr '\n' ' ')
echo "files=$FILES" >> $GITHUB_OUTPUT
echo "Changed files: $CHANGED"
fi
- name: Install Python dependencies
if: steps.changed.outputs.changed == 'true'
run: |
python -m pip install --upgrade pip
pip install uv "marimo>=0.13.3" matplotlib numpy jupyter nbformat ipykernel
- name: Render changed files only
if: steps.changed.outputs.changed == 'true'
run: |
# Restore cached _site if it exists
if [ -d "_site" ]; then
echo "Using cached _site directory"
else
echo "No cache found, doing initial render"
quarto render
exit 0
fi
# Render only changed files
for file in ${{ steps.changed.outputs.files }}; do
if [ -f "$file" ]; then
echo "Rendering: $file"
quarto render "$file"
fi
done
- name: Deploy to gh-pages
uses: quarto-dev/quarto-actions/publish@v2
with:
target: gh-pages
render: false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}How It Works
1. Cache the _site directory
- name: Cache rendered site
uses: actions/cache@v4
with:
path: _site
key: quarto-rendered-${{ github.run_number }}
restore-keys: |
quarto-rendered-GitHub Actions cache persists the _site directory between runs. The key uses the run number to save new caches, but restore-keys allows falling back to any previous cache if no exact match is found.
2. Detect changed files
PREV_COMMIT=$(git rev-parse HEAD~1)
CHANGED=$(git diff --name-only $PREV_COMMIT HEAD -- posts/*.qmd posts/*.md posts/*.ipynb 2>/dev/null || true)We compare the current HEAD with the previous commit (HEAD~1) to see what changed in this push. This is simpler than tracking the last gh-pages commit and works well for incremental builds.
3. Render only changed files
for file in ${{ steps.changed.outputs.files }}; do
if [ -f "$file" ]; then
echo "Rendering: $file"
quarto render "$file"
fi
doneInstead of quarto render (which rebuilds everything), we render each changed file individually. Quarto updates the _site directory with just the new files.
4. Deploy to gh-pages
- name: Deploy to gh-pages
uses: quarto-dev/quarto-actions/publish@v2
with:
target: gh-pages
render: false # Use pre-rendered _site from cacheThe critical part is render: false. Without this, the publish action would re-render the entire site, defeating the purpose of incremental builds. With render: false, it simply deploys the already-rendered _site directory to gh-pages, which GitHub Pages serves.
Before vs After
| Metric | Before | After |
|---|---|---|
| Files rendered per change | 145 | 1-5 |
| Build time | 5-6 minutes | 30-60 seconds |
| Failures on large notebooks | Frequent | Rare |
| Cost (GitHub minutes) | High | Low |
Caveats
First build
The first build after adding this workflow will still render everything (no cache yet). But every subsequent build will be incremental.
Cross-file changes
If you modify _quarto.yml, styles.scss, or other site-wide files, the workflow won’t detect them as changed posts. You may need to trigger a manual full build via workflow dispatch.
Cache expiration
GitHub Actions cache expires after 7 days of no access. If you don’t build for a week, you’ll get a full rebuild on the next push.
Going Further
Smart cache invalidation
You could detect changes to config files and force a full rebuild:
if git diff --name-only HEAD~1 HEAD | grep -qE "_quarto.yml|styles.scss"; then
echo "Config files changed, doing full rebuild"
quarto render
else
# Incremental build
fiParallel rendering
For multiple changed files, render them in parallel:
echo "${{ steps.changed.outputs.files }}" | xargs -P 4 -I {} quarto render {}Better cache keys
Use content hash of changed files instead of run number for more precise caching:
key: quarto-site-${{ hashFiles('posts/**') }}Summary
Incremental Quarto builds with GitHub Actions caching:
- Dramatically faster builds for small changes
- More reliable - fewer failures on problematic notebooks
- Cheaper - uses fewer GitHub Actions minutes
- Simple - under 100 lines of YAML
The next time you’re tired of waiting for Quarto to rebuild your entire site, give incremental builds a try!