Introduction
I write in Obsidian. I publish on Hashnode. Until recently, moving between the two looked like this:
- Open the vault note
- Copy the content
- Open Hashnode editor
- Paste and reformat the frontmatter manually
- Hunt for a cover image
- Upload it
- Hit publish
Somewhere between ten and fifteen minutes of friction between “I’m done writing” and “the post is live.”
I got tired of it. So I built a pipeline.
This post documents that pipeline — and it was published through it.
The Problem with Manual Publishing
My Obsidian notes have their own frontmatter schema. Hashnode has its own. They’re similar enough to feel like they should be the same thing, and different enough that copy-pasting always broke something.
The tags format was different. The title needed quoting sometimes and not others. The cover image had to be uploaded separately. The ## 📝 Draft section header had to be stripped out.
None of these are hard problems. But they were the kind of small, repetitive friction that makes you skip publishing altogether.
The Architecture
The pipeline has three components:
04_Publish note (Obsidian vault)
↓
/hashnode-publish (Claude Code skill)
converts frontmatter, generates slug, writes file
↓
post/<timestamp>/slug.md (GitHub relay repo)
↓ git push → GitHub Actions
├─ Resolve Unsplash cover image
└─ Hashnode API → live post
Each component has a single responsibility. The vault stays clean. The publishing logic lives outside it.
Component 1: The GitHub Relay Repo
The first piece is a GitHub repository whose only job is to receive markdown files and forward them to Hashnode.
The GitHub Actions workflow triggers on any push that touches post/**/*.md. The nested path pattern matters — post/*.md wouldn’t match, so every article goes into a timestamp-named subfolder: post/20260517143000/slug.md. That folder name also serves as a natural ordering key.
When the workflow fires, it runs two steps per changed file:
- Resolve Unsplash cover image — more on this below.
- Hashnode publish — calls the Hashnode API via
raunakgurud09/[email protected].
The Hashnode API key lives in GitHub Secrets. It never touches the local machine.
Component 2: The Claude Code Skill
The second piece is a Claude Code slash command: /hashnode-publish.
When invoked, it reads the current vault note, parses its frontmatter, and converts it to Hashnode’s format. Then it generates a slug from the filename, creates the timestamp folder, writes the converted file, and pushes to the relay repo.
The frontmatter conversion handles the differences between the vault schema and Hashnode’s schema: tag format, field ordering, stripping vault-specific fields, dropping the ## 📝 Draft header from the body. If description is empty, it stops and asks to fill it in first. If publish is false, it warns before proceeding.
From the writer’s perspective, the workflow becomes:
1. Write the note in Obsidian
2. Fill in the frontmatter (title, description, tags, etc.)
3. Type /hashnode-publish
4. Done
The entire publishing process — conversion, file creation, commit, push — runs in under ten seconds.
Component 3: Unsplash Cover Images
The third piece solves the cover image problem.
The vault frontmatter accepts a splash_keyword field. When it’s set and cover_image is empty, the skill passes the keyword through to the relay repo file. GitHub Actions picks it up, calls the Unsplash API, and injects the result as cover_image before passing the file to Hashnode.
# In the vault note frontmatter
splash_keyword: "developer workspace"
cover_image: ""
GitHub Actions:
splash_keyword found → GET /photos/random?query=developer+workspace
→ cover_image: https://images.unsplash.com/photo-...
→ splash_keyword removed from frontmatter
The Unsplash API key lives in GitHub Secrets alongside the Hashnode key. Nothing sensitive is stored locally.
One more thing: the Unsplash API terms encourage attribution. So whenever an image is fetched, the workflow appends a line to the article body:
*cover image: [unsplash](https://unsplash.com/)*
The cover image for this post was chosen by that step. I wrote splash_keyword: "developer workspace" and the algorithm did the rest.
The Decision to Use a Relay Repo
A simpler design would call the Hashnode API directly from the Claude Code skill. No relay repo, no GitHub Actions — just a local script with the API key.
I chose the relay repo approach for one reason: the API keys live in GitHub Secrets, not on the local machine.
This matters more than it might seem. A Claude Code skill runs with access to the local environment. Keeping secrets out of that environment, and inside a CI/CD system with audit logs and rotation tooling, is a better default. The relay repo adds one step but removes a category of risk.
There’s a secondary benefit: the relay repo is a record. Every published post has a corresponding commit with a timestamp and a slug. That’s useful.
What the Frontmatter Looks Like
A complete vault note ready for publishing looks like this:
---
title: "Post Title."
subtitle: "Subtitle here."
tags: ["tag1", "tag2"]
description: "One or two sentences describing the post."
cover_image: ""
splash_keyword: "relevant search term"
publish: true
enableTableOfContent: true
isNewsletterActivated: true
---
Three fields drive the cover image behavior:
cover_image | splash_keyword | Result |
|---|---|---|
| set | — | Use the URL directly |
| empty | set | Fetch from Unsplash |
| empty | empty | No cover image |
Lessons
The friction between “done writing” and “published” is a publishing problem, not a writing problem.
Optimizing the writing environment — better tools, better structure — doesn’t help if the last mile is still manual. The pipeline had to be built separately.
And a pattern that recurred across all three components:
Put secrets where they belong. API keys in GitHub Secrets, not environment variables, not shell configs.
It’s the same principle that came up in the SSH agent post: scope matters. A credential should only be reachable from the system that actually needs to use it.
What’s Next
The pipeline is functional. A few things I’d add eventually:
- Update existing posts: the current workflow only handles new files; updating a published post requires a separate flow
- Preview step: see the formatted output before pushing
- Japanese articles: the same pipeline works, but slug generation from Japanese filenames needs a romanization step
For now, typing /hashnode-publish and watching the commit appear in the relay repo is satisfying enough.