The one where backups keep your latest posts on your GitHub profile up to date

A while ago I wrote a post about automatically adding my latest blog posts to my GitHub profile. The idea was simple: a GitHub Action would query Hashnode's public GraphQL API, grab my most recent posts, and rewrite a section of the README.md with them. Whenever I published something new, a webhook chain would kick in and my profile would stay up to date without me lifting a finger.
It worked great. Until it did not.
The free lunch ended
If you visit https://gql.hashnode.com today, you no longer land on the friendly GraphQL playground I used back then. Instead, you get redirected to this changelog entry.
The gist of it is this:
Every API request, queries and mutations, now requires a Pro plan on your publication.
So it is not just write operations behind the paywall. Even reading my own posts through the API now requires a paid subscription. Hashnode justifies the change as an abuse-prevention measure — apparently scrapers and spammers were mirroring content and flooding feeds — which is a perfectly reasonable motivation. But the practical consequence for me was that my nice little automation suddenly stopped working.
I had two options:
Pay for Pro just to keep a
README.mdtable updated (overkill, to put it mildly).Find another source of truth for my posts.
Thankfully, that source of truth was hiding in plain sight.
The backups saved the day
If you read the original post, you might remember that Hashnode offers a GitHub integration that automatically backs up every post to a repository of your choice.
Back then I only used that repository as the trigger for my automation — a push to it fired a webhook that told my profile to go and re-query the API. The posts themselves still came from Hashnode.
Then I realized I do not need the API at all. Every post is already in that repository, as a Markdown file with all the metadata I need in its YAML frontmatter.
Here is what one of those backup files looks like at the top:
title: "The one about choosing where htmx logic belongs"
seoDescription: "Compare view-level events and server response headers to handle htmx failures. Explore the trade-offs and decide where your logic truly belongs."
datePublished: 2026-06-22T21:09:14.210Z
cuid: cmqppkjkw00000bj7cakd4owq
slug: the-one-about-choosing-where-htmx-logic-belongs
cover: https://cdn.hashnode.com/uploads/covers/642d433eeaad3d174f737099/5bc78409-7883-4e5b-9ec4-afc3251424e8.png
tags: http, javascript, html, security, hypermedia, htmx
Everything I was fetching over the network — title, slug, datePublished, cover — is right there. No API, no token, no Pro plan. Just files on disk.
The new GitHub action
So I created a replacement for the old action, backpackerhh/github-latest-blog-posts, whose most important design decision is captured in the following:
This action does not call any external HTTP API. The consumer workflow is responsible for checking out the blog-posts repository and passing its path to this action.
This is a fundamental shift in responsibility. The old action fetched the data. The new one reads it. The workflow checks out the backup repository, and the action just parses the Markdown files it finds locally.
The metadata and configuration of the action live in action.yml, and its inputs tell the whole story of how the philosophy changed:
| Input | Required | Default |
|---|---|---|
POSTS_DIR |
Yes | — |
BLOG_BASE_URL |
No | https://davidmontesdeoca.dev |
README_FILE |
No | ./README.md |
OPENING_COMMENT |
No | <!-- HASHNODE_POSTS:START --> |
CLOSING_COMMENT |
No | <!-- HASHNODE_POSTS:END --> |
MAX_POSTS |
No | 5 |
COMMIT_MESSAGE |
No | chore(docs): update recent blog posts |
Notice what is gone: there is no HASHNODE_PUBLICATION_ID and no GITHUB_TOKEN. The only required input is now POSTS_DIR — the path to the local directory where the Markdown posts live.
A couple of things worth explaining:
BLOG_BASE_URLis used to reconstruct each post's public URL. Since the backups only store theslug, the action builds the link as${BLOG_BASE_URL}/${slug}. This means my profile links point to my own domain rather than to Hashnode, which I actually prefer.OPENING_COMMENTandCLOSING_COMMENTkeep the sameHASHNODE_POSTSmarkers I already had in myREADME.md, so I did not have to touch the placeholders at all. Backwards-compatible by happy accident.
The runs section describes the execution environment. It uses node24 as the runtime and dist/index.js as the entry point — same compiled-code approach as the old action, where the contents of the dist directory are what actually run.
As for how it actually reads the posts, the logic is refreshingly boring, and I mean that as a compliment.
The action:
Scans
POSTS_DIRfor Markdown files.Parses the YAML frontmatter of each file, expecting
title,seoDescription,datePublished,slug, andcover.Skips (with a warning) any file missing the required fields.
Sorts the posts by
datePublished.Takes the newest
MAX_POSTSand formats them into a table between the comment markers in theREADME.md.
If everything goes well, a commit is created with the changes — exactly the behavior I had before. From the outside, my profile updates the same way. Under the hood, not a single byte crosses the network to Hashnode.
Wiring it together
Here is the updated workflow in my profile repository:
name: Update Latest Blog Posts
on:
workflow_dispatch:
# for trigger via webhooks
repository_dispatch:
types: [trigger]
jobs:
update-posts:
runs-on: ubuntu-latest
name: Update Posts
steps:
- name: Checkout profile repo
uses: actions/checkout@v7
- name: Checkout blog-posts repo
uses: actions/checkout@v7
with:
repository: backpackerhh/blog-posts
path: .blog-posts
ref: main
- name: Update README with latest posts
uses: backpackerhh/github-latest-blog-posts@main
with:
POSTS_DIR: .blog-posts
Once again, a fair amount is going on here, so let me explain it:
The workflow can still be triggered manually via
workflow_dispatch.It can still be triggered by a webhook event, listening for the
triggerevent type — the exact same trigger my dispatcher sends.The first Checkout step grabs my profile repository, as before.
The second Checkout step is the key change. It checks out
backpackerhh/blog-postsinto a.blog-postsdirectory. This is how the data gets onto the runner without any API call.The final step runs the new action, pointing
POSTS_DIRat that.blog-postsdirectory.
The best part of this migration is that dispatcher.yml in my blog-posts repository is untouched:
name: Dispatcher
on:
push:
branches: [main]
jobs:
dispatch_event:
name: Dispatch event
runs-on: ubuntu-latest
timeout-minutes: 2
steps:
- name: Dispatch
run: |
curl -L \
-X POST \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer ${{ secrets.DISPATCH_TOKEN }}" \
https://api.github.com/repos/backpackerhh/backpackerhh/dispatches \
-d '{"event_type":"trigger"}'
The chain of events is identical to what I described in the previous post:
I publish, update, or delete a post on Hashnode.
Hashnode's GitHub integration pushes the change to
backpackerhh/blog-posts.That push to
maintriggers the dispatcher.The dispatcher sends a
repository_dispatchevent with typetriggerto my profile repository.My profile's workflow wakes up, checks out the backups, and rewrites the
README.md.
The only link in the chain that changed is step 5, and even there, only the source of the data changed — from a remote API to a local checkout.
Fewer moving parts, fewer things to break
Looking back, this migration turned out to be a small blessing in disguise. Comparing the two setups side by side:
| Old setup | New setup | |
|---|---|---|
| Data source | Hashnode GraphQL API | Local Markdown backups |
| Network call | Yes (per run) | None |
| Hashnode Pro required | Yes (now) | No |
| Secrets needed by the action | HASHNODE_PUBLICATION_ID, GITHUB_TOKEN |
None |
| Post URLs point to | Hashnode | My own domain |
| Failure surface | API downtime, rate limits, auth, schema changes | Frontmatter parsing |
The new approach is not only cheaper — it is more resilient. There is no API to go down, no rate limit to hit, no token to expire, and no GraphQL schema that might change under me. The backup repository is the single source of truth, and it is the same data Hashnode itself would have served me. If Hashnode ever changes the shape of that frontmatter, that is the only thing I would need to adapt.
Conclusion
When Hashnode put its API behind a Pro plan, my first reaction was mild annoyance at having something that worked perfectly well suddenly break. But the fix ended up leaving me with a setup that has fewer dependencies, no secrets in the critical path, and no external service to depend on at runtime.
The trigger mechanism I built back then survived intact — I just swapped the data source from a remote API to the local backups that were already being created for me. My original solution reached out over the network for data that was, in fact, already sitting on disk, and it took a paywall to make me notice the redundancy.
Sometimes the constraint that feels like a setback is the exact push you need toward the simpler design you should have had all along.
Thank you for reading and see you in the next one!



