One Source, Two Targets: Auto-Syncing Your GitHub Profile README on Every Build
Every time I run quarto render, my GitHub profile README updates itself automatically — no copy-pasting, no manual commit, no drift. This post walks through the full mechanism: Quarto’s multi-format pipeline, a recursion-guarded post-render Bash script, and a handful of sed fixes for Quarto’s GFM quirks. The result is a single about.qmd that feeds both your website and your GitHub profile, always in sync.
The drift problem
If you maintain a personal website and a GitHub profile README, you know the drill: update your bio in one place, forget the other. Add a certification here, miss it there. Two files, two truths, one inevitable inconsistency.
My website is built with Quarto. The about page uses the Trestles template — a two-column hero layout with a circular profile image, social links in a sidebar, and rich HTML features (callout blocks, column divs, iframes) that simply do not translate to GitHub-Flavored Markdown (GFM). So the two versions naturally diverged.
The fix: a single about.qmd that renders to both HTML and GFM, with a post-render Bash script that diffs and pushes the README to the profile repo on every build. One edit, two outputs, zero drift.
Here is what the whole system looks like:
quarto render (full site)
│
├──► docs/about.html ← website, Trestles layout
│
└──► post-render hook
│
├──► quarto render about.qmd --to gfm → /tmp/README.md
├──► git clone guillaumegilles/guillaumegilles
├──► diff README.md (skip push if unchanged)
└──► git commit && git push → github.com profile
How Quarto’s multi-format pipeline works
Quarto is built on top of Pandoc. When you write a .qmd file, Quarto first executes any code cells (via Knitr or Jupyter), then passes the resulting Markdown to Pandoc for the final format conversion — HTML, PDF, GFM, etc.
Format families and .content-visible
The .content-visible when-format="..." div syntax uses Quarto’s format family concept, not the raw Pandoc output format. The mapping is crucial and easy to get wrong:
--to argument |
Format family | .content-visible guard |
|---|---|---|
html |
html |
when-format="html" ✅ |
gfm |
markdown |
when-format="markdown" ✅ |
gfm |
gfm |
when-format="gfm" ❌ silently fails |
pdf |
latex |
when-format="latex" ✅ |
commonmark |
markdown |
when-format="markdown" ✅ |
GFM is a dialect of Markdown processed by the commonmark Pandoc writer. Quarto’s format resolution maps it to the markdown family — so when-format="gfm" always evaluates to false without any warning.
Run quarto render about.qmd --to gfm --verbose and look for the format: line in the output. It will show markdown, not gfm.
The freeze cache and multi-format renders
If your project uses freeze: auto in _quarto.yml, Quarto caches code execution results in _freeze/. When the post-render hook calls quarto render about.qmd --to gfm, Quarto re-uses the frozen execution cache, so the second render is fast even if the document contains expensive computations.
One subtlety: the freeze hash is keyed on the source content, not the output format. Two renders of the same file to different formats share the same execution cache, which is correct — the code output should not change between HTML and GFM.
Structuring about.qmd
The file has three content zones:
Zone 1 — HTML-only (website)
Wrap everything that uses Quarto-specific HTML features inside .content-visible when-format="html". This includes the Trestles hero section, multi-column divs, callout blocks, and any raw HTML:
::: {.content-visible when-format="html"}
::: {#hero-heading}
# Your headline here
> Your tagline — one sharp sentence.
:::
::::: {.columns}
:::: {.column width="60%"}
Your main content here.
::::
:::: {.column width="40%"}
::: {.callout-tip}
## Sidebar note
Something that complements the main text.
:::
::::
:::::
:::The #hero-heading id is required by Quarto’s Trestles template. Without it, the template does not know where to inject the profile image and social links sidebar, and the page renders as plain text.
Zone 2 — GFM-only (GitHub profile)
GitHub renders a special README from the repo your-username/your-username. It supports a useful subset of HTML — <div align="center">, <img>, <p> — but not Quarto divs, callouts, or the ::: fencing syntax. Use when-format="markdown" (not "gfm"):
::: {.content-visible when-format="markdown"}
<!-- ⚠️ AUTO-GENERATED — do not edit directly. -->
<!-- Source: github.com/your-username/your-username.github.io -->
<div align="center">
<img src="https://yoursite.dev/assets/profile.png"
width="150" alt="Your Name" style="border-radius: 50%;" />
### Your Name
**Your one-liner.**
[](https://yoursite.dev)
</div>
---
| Platform | Profile | Focus |
|---|---|---|
| **GitHub** | [your-username](https://github.com/your-username) | Open source |
:::The <!-- AUTO-GENERATED --> comment is important. Once you automate this, someone — including future you — will try to edit the GitHub file directly. The comment makes the source of truth explicit.
The post-render script
The script lives at scripts/update-profile-readme.sh and runs automatically after every quarto render. Here is a section-by-section walkthrough.
Defensive shell settings
scripts/update-profile-readme.sh
#!/usr/bin/env bash
set -euo pipefailset -euo pipefail is the standard trio for defensive scripting:
-e— exit immediately if any command returns a non-zero status-u— treat unset variables as errors (catches typos like$TMDIR)-o pipefail— a pipeline fails if any command in it fails, not just the last one
The recursion guard
if [ "${PROFILE_README_RUNNING:-}" = "1" ]; then
exit 0
fi
export PROFILE_README_RUNNING=1This is the most non-obvious part of the setup. Without it, here is exactly what happens:
- You run
quarto render. - The full site renders.
- The post-render hook fires and calls
quarto render about.qmd --to gfm. - Quarto detects a
_quarto.ymlin the project directory. - Quarto treats this as a project render and re-renders the whole site.
- The post-render hook fires again. → Go to step 3. Infinite loop.
The guard sets PROFILE_README_RUNNING=1 before the first nested render. When Quarto fires the hook the second time, the variable is already set, the script exits immediately, and the loop terminates.
The :- in ${PROFILE_README_RUNNING:-} is a Bash default-value expansion: if the variable is unset, it expands to an empty string rather than triggering -u’s “unset variable” error.
Temporary directory with cleanup trap
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
WORK="$(mktemp -d)"
cleanup() { rm -rf "$WORK"; }
trap cleanup EXITmktemp -d creates a unique directory under /tmp. The trap cleanup EXIT deletes it regardless of how the script exits — normal completion, error, or signal. Without the trap, a failed render leaves stale directories in /tmp.
SCRIPT_DIR uses cd && pwd rather than just dirname "$0" because $0 can be a relative path when called by Quarto. The cd + pwd combination always returns an absolute path.
Rendering about.qmd to GFM
echo "📄 Rendering about.qmd → GFM README.md …"
quarto render "$PROJECT_DIR/about.qmd" --to gfm \
--output README.md --output-dir "$WORK" 2>/dev/null
if [ ! -f "$WORK/README.md" ]; then
echo "❌ GFM render failed — README.md not produced."
exit 1
fi--to gfm invokes Pandoc’s commonmark_x (GitHub-Flavored Markdown) writer. --output README.md renames the output (instead of the default about.md), and --output-dir "$WORK" places it in the temp directory. 2>/dev/null suppresses Quarto’s informational stderr to keep the post-render log clean.
The existence check matters: set -e catches non-zero exit codes, but quarto render can exit 0 while still failing to produce output in some edge cases.
Cleaning up Quarto’s GFM artifacts
# 1. Remove the auto-injected "# About" title (first two lines)
sed -i '1{/^# About$/d}' "$WORK/README.md"
sed -i '1{/^$/d}' "$WORK/README.md"
# 2. Fix shields.io badge URLs where Quarto appended .png
sed -i 's/logoColor=fff\.png/logoColor=fff/g' "$WORK/README.md"Two artifacts need fixing:
Title injection. Quarto reads the YAML title: field and prepends it as an # H1 heading in GFM output. The first sed command deletes that line only if it matches # About; the second deletes the blank line that follows.
Image extension appending. Quarto’s GFM writer applies its default-image-extension heuristic to every URL it identifies as an image path. Shields.io badge URLs end in ...&logoColor=fff — Quarto mistakes the three-letter hex code for a file extension and appends .png, breaking the badge. The sed substitution strips the spurious extension.
A more general fix is to set default-image-extension: "" in _quarto.yml, but that changes behaviour for all GFM renders across the project. The targeted sed is safer and requires no project-wide configuration change.
Idempotent push — only if the content changed
echo "📦 Cloning $REPO …"
if ! git clone --depth 1 "git@github.com:${REPO}.git" \
"$WORK/profile-repo" 2>/dev/null; then
echo "⚠️ Could not clone $REPO — skipping push."
trap - EXIT # keep the temp dir so you can inspect the file
exit 0
fi
if diff -q "$WORK/README.md" "$WORK/profile-repo/README.md" &>/dev/null; then
echo "✅ Profile README is already up-to-date — nothing to push."
exit 0
fi
cp "$WORK/README.md" "$WORK/profile-repo/README.md"
cd "$WORK/profile-repo"
git add README.md
git commit -m "chore: sync profile README from ggilles.dev
Auto-generated from about.qmd by post-render script."
git push
echo "🚀 Profile README pushed to github.com/${REPO}"--depth 1 requests a shallow clone — only the latest commit. This is significantly faster than a full clone for a profile repo that may have hundreds of commits; we only need the current README.md for the diff.
The diff -q check makes the push idempotent: if nothing changed in about.qmd since the last build, no empty commit pollutes the profile repo’s history.
If cloning fails (missing SSH key, network issue, wrong repo name), the script degrades gracefully: it disables the cleanup trap so the generated README.md survives in /tmp for manual inspection, then exits 0 so the overall quarto render does not fail.
Wiring it into _quarto.yml
_quarto.yml
project:
type: website
output-dir: docs
post-render:
- scripts/update-profile-readme.shThe post-render key accepts a list of scripts executed in order, from the project root, after all formats have rendered. Make the script executable before the first build:
chmod +x scripts/update-profile-readme.shThat is the entire integration. No plugins, no GitHub Actions, no external services — just a shell script that Quarto calls for you.
Authentication
The script uses git@github.com: (SSH). This requires:
- An SSH key pair on the machine running
quarto render - The public key added to GitHub under Settings → SSH and GPG keys
If you prefer HTTPS, swap the clone URL to https://github.com/${REPO}.git and configure a credential helper or a personal access token.
For CI/CD on GitHub Actions, use a deploy key scoped to the profile repo, or rely on GITHUB_TOKEN with cross-repo write permissions:
.github/workflows/publish.yml
- name: Render site and sync profile README
env:
GITHUB_TOKEN: ${{ secrets.PROFILE_REPO_TOKEN }}
run: |
git config --global user.email "ci@yoursite.dev"
git config --global user.name "CI Bot"
quarto renderDebugging
Nothing is being pushed. Run the script directly with xtrace mode enabled:
bash -x scripts/update-profile-readme.sh-x prints each command and its expanded arguments before executing it, making it easy to spot where the script diverges from expectations.
Shields.io badges show broken images. Check for unexpected .png suffixes in the generated file:
grep "logoColor=.*\.png" /tmp/README.mdAdd additional sed rules for any other URL patterns Quarto mangles.
The recursion guard fires unexpectedly. If PROFILE_README_RUNNING is left set in your shell from a previous interrupted run, new renders will silently skip the README sync. Reset it with:
unset PROFILE_README_RUNNINGThe diff always reports changes even when about.qmd hasn’t changed. Check whether your GFM zone contains timestamps, random values, or anything that changes on every render. Content that is non-deterministic between runs will produce a fresh push on every build.
The result
Every quarto render now does double duty:
- Website →
docs/about.html— full Trestles layout with profile image sidebar, column divs, callout blocks, and social links. - GitHub profile README → auto-pushed to
your-username/your-usernamewith the centered GFM header, badge row, and all shared bio content.
One source file. Two outputs. Zero drift. This is the POSSE principle (Publish on Own Site, Syndicate Elsewhere) applied at the build-system level — your website is the canonical source; GitHub is one of its syndication targets.
If you adapt this for your own site, feel free to open a GitHub issue or reach out on LinkedIn — happy to help you debug it.