Wiring Obsidian to a Static Site with GitHub Actions
The implementation behind a publish-flag pipeline: cross-repo triggers, pulling a private vault, converting Obsidian Markdown, and the write-back loop to avoid.
Part 1 made the case for treating your notes app as your CMS. This is the wiring: two repositories — a private vault (the source) and a static site (the published view) — connected so that pushing to the vault makes the site rebuild itself, and the site writes a status back to the vault when it’s done.
The architecture
flowchart TB
Push[Push to vault repo] -->|repository_dispatch| WF[Site workflow]
subgraph Site[Site repo · GitHub Actions]
WF --> Checkout[Checkout vault · PAT]
Checkout --> Export[obsidian-export → clean Markdown]
Export --> Filter{publish: true?}
Filter -- yes --> Content[Write _posts / _projects]
Filter -- no --> Drop[Skip]
Content --> Build[Build → validate → deploy]
end
Build -->|push published_at| Vault[(Vault repo)]
Vault -. loop-guarded .-> Push
Three things have to cross the repo boundary, and GitHub Actions has a primitive for each.
1. Trigger one repo from another
The vault’s on: push workflow fires a repository_dispatch event at the site repo (a small API call with a token). The site repo listens for it:
1
2
3
4
on:
repository_dispatch:
types: [vault-updated]
workflow_dispatch: # manual fallback
That’s the “magic happens in the background” link — a vault push becomes a site build.
2. Pull a private repo into the workflow
The site workflow checks out the vault with actions/checkout, authenticated by a fine-grained PAT (the vault is private):
1
2
3
4
5
- uses: actions/checkout@v4
with:
repository: you/your-vault
token: $
path: vault
3. Convert the content — don’t reinvent it
Obsidian Markdown isn’t standard Markdown: [[wikilinks]], ![[embeds]], attachments. Rather than hand-roll that, run obsidian-export (a Rust CLI) to emit clean Markdown, then filter by the publish flag and drop the survivors into _posts / _projects. A build-time content validator then gates the deploy, so a malformed note can never reach the live site.
The gotcha: the write-back loop
The nice touch — writing published_at back to the note so the vault knows it’s live — is also the trap. The site pushes a commit to the vault → that push re-fires the “vault updated” trigger → the site runs again → and around it goes.
Break the cycle deliberately, with one of:
- the vault’s trigger workflow ignores bot-authored commits (or
[skip ci]), - a
paths-ignoreso front-matter-only write-backs don’t trigger, or - make the sync idempotent — if nothing real changed, it’s a no-op and converges.
This is the part that bites people; design it in from the start, not after the first infinite loop drains your Actions minutes.
Auth, in one line
A fine-grained PAT (or a GitHub App, if you want short-lived tokens and tighter scopes) with read + write on the vault and dispatch on the site repo, stored as secrets. One credential wires all three moves above.
Pitfalls to watch
- The loop — covered above; the #1 way this goes wrong.
- Token scope — least privilege; a leaked broad PAT touches both repos.
- Conversion fidelity — test embeds/attachments early; that’s where Obsidian and plain Markdown diverge most.
- No validation gate — without it, one bad note breaks a live page. Gate the deploy.
Takeaway
Cross-repo automation in GitHub Actions comes down to three primitives — repository_dispatch to trigger, an authenticated checkout to pull, and an authenticated push to write back. Lean on obsidian-export for the hard conversion, guard the write-back loop, and gate the deploy with validation. The result is the payoff from Part 1: you write and flag a note, and a pipeline quietly turns it into a deployed page.