Post

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.

Wiring Obsidian to a Static Site with GitHub Actions

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-ignore so 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.

This post is licensed under CC BY 4.0 by the author.