Recently GitHub released a new feature for the profile pages: You can now create
a special repository named
github.com/<username>/<username> with a
file which will be displayed to visitors of your GitHub profile. You are free to
put anything in it that github-flavored markdown supports.
I already maintain a personal blog (you are viewing it right now) with
occasional posts, so I thought it would be nice to have a list of the latest
posts linked on my GitHub profile's
with some introductory information about me.
While you can edit the file as often as you want manually, that seems like an
additional step to do every time I upload a new post to the blog. What I really
want is to fetch the latests posts automatically from my website and generate
README.md dynamically from it.
Be warned, that this feature was not really intedend to be used like this and while the result pretty cool, quite some hackery is required!
Let's start by writing the actual generator. You can do this in any language you
want as long as it's supported by github actions. I went with Go because of it's
text/template package wich will be used to generate the markdown
My website is build with hugo and provides a RSS feed with all posts. This is the perfect candidate to fetcht the data we will need.
I used the
github.com/SlyMarbo/rss package, a small library for simplifying
the parsing of RSS and Atom feeds with very little dependencies that
gives us easy to work with structs for the items of a RSS URL
feed, err := rss.Fetch("https://pablo.tools/index.xml") if err != nil
That's all that is required to get a neatly parsed slice of
are easy to work with. While this is a very bad way of handling errors in
general, for this purpouse treating any error as fatal will be fine and ensure
that the generation does not continue so at least the latest working version of
README.md is visible if something goes wrong.
My RSS feed contains all items on the website, including things like the
about pages that are not really posts and should not be included
in the list. These have to be filtered out. Additionally, I have structured my
blog in different topics and would like to group the posts by that categories.
These categories are not something that is present as RSS metadata, but the URL follows a fixed scheme and we can use this to do filtering and sorting. The links to the the posts are composed like this:
Let's start by creating a suitable structure that we can later pass to the template:
posts := make(map[string]*rss.Item)
That's quite a mouthful.
posts is a map having the categories as string
keys and pointers to individual slices of
rss.Items as values.
From the feed fetched above we can now iterate over all posts, filter and append
to the correct slices. The name of the categories are taken from the URL and
capitalized. Also this approach has the added benefit of adding the posts in the
correct order, as the feed is already ordered by date.
for _, v := range feed.Items
Easy, right? All that is left here is to read the template from a file and
render it to the standard output passing our
posts structure as input to it.
t, err := template.ParseFiles("template") err = t.Execute(os.Stdout, posts)
You can find the complete code of the script in the repository
The template is contained in a separate file. Go's
engine will allow us to put anything in here, not only markdown, and provide
the additional template directives surrounded with
}} we can use to fill
in the dynamic content.
My template starts with
some static markdown and embedded html blocks, use this as you like. The last
few lines is where all the magic happens. This is where the
structure we created before will start to make sense, as it allows for very simple
code in the template.
: - () ##
All that is needed are two nested loops and a check for the maximum number of
posts we'd like to show. The first loop will over all categories, the second
one over all posts of that cathegory. Since the outer structure is a map we
can use the keys and values to access all needed strings easily. The headings
will just be the keys while the values (
rss.Item slices) are passed to the
The inner loop contains a check using the
lt (less than) pipeline provided by
text/template package to limit the output to 5 posts per category. This
will be the 5 most recent posts of every category.
That's all the code! You could do anything you want here to generate dynamic content: Pass the output of other functions, fetch other data from the internet, generate a picture... The options are limited by your imagination.
At this point we have a working generator. We can test it by running it locally
and it will print out markdown. Copy-paste it in your
README.md and you will see
the output it generates.
Now let's make this automatic. Head over to the GitHub Actions tab of the
repository and start with the recommended
Go Workflow action. It will create
.github/workflows/go.yml file with some default content. We need to modify
a few things for this purpouse.
Keep in mind our final goal is to generate a
README.md markdown file, so
let's do exactly that now. The
go run command will interpret the go code
without compiling it to a executable. Just add an additional step to the
build: that runs our builder and pipes the output to
- name: Build and Generate run: go run builder.go > README.md
To put the file back into the repository itself, another step is needed. We will
check for changed files, commit and push them from and to GitHub itself.
Yes, it reminds me of the movie
- name: Commit and push run: |- git diff git config --global user.email "[email protected]" git config --global user.name "README-bot" git add -A git commit -m "Updated content" || exit 0 git push
At this point if you push to the repository, a new file should be generated and
displayed in your profile page. This is great for testing, but adding a
time-based execution of the workflow is what we really want. Luckily GitHub
actions support a
on: directive with cron-like syntax to run workflows on
predefined times. I keep the
push trigger aswell as it might come in handy if
I change the template.
on: push: workflow_dispatch: schedule: - cron: '32 * * * *'
That's it, you're done! The action will run on the specified interval and also on any push. You can track the output on the github workflow status page:
If everything worked correcly it will run builder which fetches the RSS feed, extracts the information, generates a markdown file, commits and pushes it back to the repository. Have fun customzing it to your liking ;)