Simon Willison

Simon Willison github README

Building a self-updating profile README for GitHub

GitHub quietly released a new feature at some point in the past few days profile READMEs.

Create a repository with the same name as your GitHub account (in my case that’s, add a to it and GitHub will render the contents at the top of your personal profile page—for me that’s

I couldn’t resist re-using the trick from this blog post and implementing a GitHub Action to automatically keep my profile README up-to-date.

Visit and you’ll see a three-column README showing my latest GitHub project releases, my latest blog entries and my latest TILs.

I’m doing this with a GitHub Action in build.yml. It’s configured to run on every push to the repo, on a schedule at 32 minutes past the hour and on the new workflow_dispatch event which means I get a manual button I can click to trigger it on demand.

The Action runs a Python script called which does the following:

  • Hits the GitHub GraphQL API to retrieve the latest release for every one of my 300+ repositories

  • Hits my blog’s full entries Atom feed to retrieve the most recent posts (using the feedparser Python library)

  • Hits my TILs website’s Datasette API running this SQL query to return the latest TIL links

It then turns the results from those various sources into a markdown list of links and replaces commented blocks in the README that look like this:

<!-- recent_releases starts -->
<!-- recent_releases ends -->

The whole script is less than 150 lines of Python .


from python_graphql_client import GraphqlClient
import feedparser
import httpx
import json
import pathlib
import re
import os

root = pathlib.Path(__file__).parent.resolve()
client = GraphqlClient(endpoint="")

TOKEN = os.environ.get("SIMONW_TOKEN", "")

def replace_chunk(content, marker, chunk):
    r = re.compile(
        r"<!\-\- {} starts \-\->.*<!\-\- {} ends \-\->".format(marker, marker),
    chunk = "<!-- {} starts -->\n{}\n<!-- {} ends -->".format(marker, chunk, marker)
    return r.sub(chunk, content)

def make_query(after_cursor=None):
    return """
query {
  viewer {
    repositories(first: 100, privacy: PUBLIC, after:AFTER) {
      pageInfo {
      nodes {
        releases(last:1) {
          nodes {
        "AFTER", '"{}"'.format(after_cursor) if after_cursor else "null"

def fetch_releases(oauth_token):
    repos = []
    releases = []
    repo_names = set()
    has_next_page = True
    after_cursor = None

    while has_next_page:
        data = client.execute(
            headers={"Authorization": "Bearer {}".format(oauth_token)},
        print(json.dumps(data, indent=4))
        for repo in data["data"]["viewer"]["repositories"]["nodes"]:
            if repo["releases"]["totalCount"] and repo["name"] not in repo_names:
                        "repo": repo["name"],
                        "release": repo["releases"]["nodes"][0]["name"]
                        .replace(repo["name"], "")
                        "published_at": repo["releases"]["nodes"][0][
                        "url": repo["releases"]["nodes"][0]["url"],
        has_next_page = data["data"]["viewer"]["repositories"]["pageInfo"][
        after_cursor = data["data"]["viewer"]["repositories"]["pageInfo"]["endCursor"]
    return releases

def fetch_tils():
    sql = "select title, url, created_utc from til order by created_utc desc limit 5"
    return httpx.get(
        params={"sql": sql, "_shape": "array",},

def fetch_blog_entries():
    entries = feedparser.parse("")["entries"]
    return [
            "title": entry["title"],
            "url": entry["link"].split("#")[0],
            "published": entry["published"].split("T")[0],
        for entry in entries

if __name__ == "__main__":
    readme = root / ""
    releases = fetch_releases(TOKEN)
    releases.sort(key=lambda r: r["published_at"], reverse=True)
    md = "\n".join(
            "* [{repo} {release}]({url}) - {published_at}".format(**release)
            for release in releases[:5]
    readme_contents =
    rewritten = replace_chunk(readme_contents, "recent_releases", md)

    tils = fetch_tils()
    tils_md = "\n".join(
            "* [{title}]({url}) - {created_at}".format(
            for til in tils
    rewritten = replace_chunk(rewritten, "tils", tils_md)

    entries = fetch_blog_entries()[:5]
    entries_md = "\n".join(
        ["* [{title}]({url}) - {published}".format(**entry) for entry in entries]
    rewritten = replace_chunk(rewritten, "blog", entries_md)"w").write(rewritten)

Things I’ve learned (TIL)

Django history