Auto-Updating Your GitHub README with Your Latest Blog Posts
Your GitHub profile README is essentially your developer homepage. It's the first thing people see when they visit your profile, so keeping it fresh and relevant matters. But manually updating it every time you publish a new post? That's the kind of toil that should be automated away.
In this post, I'll walk through exactly how I set up my GitHub profile to automatically pull in my latest blog posts from my RSS feed — no manual updates, no third-party services, just a small Python script and a GitHub Action doing the work for me.
The Big Picture
The system has two moving parts:
- A Python script that fetches my RSS feed, parses the latest posts, and rewrites a section of my
README.md - A GitHub Action that runs that script on a schedule and commits any changes back to the repo
Together, they mean my README stays in sync with my blog automatically. Every morning, GitHub spins up a tiny virtual machine, runs the script, and if anything changed, commits the update. I don't have to think about it.
File 1: The GitHub Action Workflow
name: Update Blog Posts
on:
schedule:
- cron: '0 6 * * *'
workflow_dispatch:
jobs:
update-readme:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.x'
- name: Install dependencies
run: pip install feedparser
- name: Update README with blog posts
run: python scripts/update_readme.py
- name: Commit and push if changed
run: |
git config --global user.name 'github-actions[bot]'
git config --global user.email 'github-actions[bot]@users.noreply.github.com'
git add README.md
git diff --cached --quiet || git commit -m "chore: update latest blog posts"
git push
This file lives at .github/workflows/blog-posts.yml in your profile repo. Let's break down the important decisions.
Why a cron schedule?
The schedule trigger with cron: '0 6 * * *' tells GitHub to run this workflow every day at 6am UTC. That's it. You publish a post, and within 24 hours it shows up on your profile without you touching anything.
The workflow_dispatch trigger alongside it is just a "run now" button — useful for testing, or for immediately reflecting a new post rather than waiting for the next scheduled run.
Why ubuntu-latest?
GitHub Actions runners are virtual machines, and ubuntu-latest is the most common, well-supported option. It's fast to spin up, has Python available, and is free for public repos. There's no reason to use anything else for a simple task like this.
Why actions/checkout@v4 and actions/setup-python@v5?
These are official GitHub Actions maintained by GitHub themselves. checkout clones your repo into the runner so the script can read and modify README.md. setup-python ensures a specific Python version is available.
The @v4 and @v5 version pins matter. Earlier versions ran on Node.js 16/20, which GitHub is deprecating. Pinning to the latest major versions ensures the workflow keeps running without warnings or failures as GitHub updates their infrastructure.
The "only commit if something changed" trick
This line is doing something clever:
git diff --cached --quiet || git commit -m "chore: update latest blog posts"
The || means "run the right side only if the left side fails." git diff --cached --quiet exits with a failure code when there are staged changes — so this only commits if the README actually changed. No new posts? No unnecessary commit cluttering your history.
File 2: The Python Script
import feedparser
import re
from datetime import datetime
FEED_URL = "https://owain.codes/rss"
README_PATH = "README.md"
MAX_POSTS = 5
START_MARKER = "<!-- BLOG-POST-LIST:START -->"
END_MARKER = "<!-- BLOG-POST-LIST:END -->"
def fetch_posts():
feed = feedparser.parse(FEED_URL)
posts = []
for entry in feed.entries[:MAX_POSTS]:
title = entry.title
link = entry.link
date = ""
if hasattr(entry, "published_parsed") and entry.published_parsed:
date = datetime(*entry.published_parsed[:3]).strftime("%b %d, %Y")
posts.append(f"- [{title}]({link}){' — ' + date if date else ''}")
return posts
def update_readme(posts):
with open(README_PATH, "r") as f:
content = f.read()
new_section = START_MARKER + "\n" + "\n".join(posts) + "\n" + END_MARKER
updated = re.sub(
re.escape(START_MARKER) + ".*?" + re.escape(END_MARKER),
new_section,
content,
flags=re.DOTALL
)
with open(README_PATH, "w") as f:
f.write(updated)
if __name__ == "__main__":
posts = fetch_posts()
update_readme(posts)
Why feedparser?
RSS feeds are XML under the hood, and parsing XML correctly — handling edge cases, different date formats, encoding quirks — is tedious. feedparser is a battle-tested library that abstracts all of that away. It handles RSS 2.0, Atom, and most feed variants without you needing to think about the differences.
Why HTML comment markers?
The markers <!-- BLOG-POST-LIST:START --> and <!-- BLOG-POST-LIST:END --> are the key to the whole approach. They're invisible in the rendered README but act as anchors for the script to find and replace only the blog post section, leaving everything else in your README completely untouched.
This is a widely adopted pattern in the GitHub README automation community, and for good reason — it's simple, robust, and doesn't require the script to understand the rest of your README's structure at all.
Why regex for the replacement?
re.sub(
re.escape(START_MARKER) + ".*?" + re.escape(END_MARKER),
new_section,
content,
flags=re.DOTALL
)
The re.DOTALL flag makes . match newlines too, so the pattern captures everything between the markers regardless of how many lines of posts are in there. re.escape ensures the marker strings are treated as literal text, not regex syntax. The .*? is non-greedy, so it matches as little as possible between the markers — important if you ever had two such sections.
Setting It Up
Drop these two files into your profile repo (the one named <your-username>/<your-username>), then add the markers to your README.md:
## 📝 Latest Blog Posts
<!-- BLOG-POST-LIST:START -->
<!-- BLOG-POST-LIST:END -->
Make sure the repo has Actions write permissions enabled under Settings → Actions → General → Workflow permissions, then trigger a manual run to see it work immediately.
After that, it just runs itself.
Why This Approach Over a Third-Party Service?
There are services and pre-built GitHub Actions that do something similar. I chose this route for a few reasons: it's transparent (you can read every line of what's running), it has zero external dependencies beyond GitHub itself, and it's trivially easy to customise. Want to show 10 posts instead of 5? Change one number. Want to add the post's description? Add one line to the script.
Owning the code means owning the behaviour.
To see the end result, pop over to my github profile