NC

Self-updating GitHub README

Posted on . Tagged with: ruby, github.

Back in 2020, GitHub added profile READMEs. I jumped on it and created mine when they silently launched the feature but then did …nothing with it.

As the profile README is implemented as a special repository which gets rendered on your profile page, I’d wanted to build something that would auto-update and show contributions after reading that Simon Willison had done the same. I was never going to remember to update these myself, but as it’s just a GitHub repo, we can use GitHub Actions to make it possible.

Screenshot showing a GitHub README with contributions and blog posts
GitHub README with contributions and blog posts

Fetching recent cross-GitHub activity

I often make contributions across various different repos and I thought it’d be interesting to show these off. I might be working on something quite interesting at work and usually when I do so, I end up opening issues or contributing PRs to various things. I started off by trying to use the GitHub GraphQL API, but the complexity was a lot for a query which was better off calling the Search API.

I already have a search I use to check what open issues and PRs I have that I should follow up with, so that was where I started:

is:open author:nickcharlton archived:false sort:updated-desc

We want archived items (to see closed or merged issues/PRs), but otherwise that’s what we pass through to the API:

headers = {
  "User-Agent" => "Recent GitHub Contributions (#{username})",
  "Accept" => "application/vnd.github+json",
}

client = Excon.new("https://api.github.com/search/issues", headers: headers)

response = client.get(query: {
  "q" => "author:#{username} sort:updated-desc is:public", per_page: count
})

From here, I’m repacking as a Contribution object (which is just a PORO with the keys below) so that it’s a little easier to work with later on:

data = JSON.parse(response.body, symbolize_names: true)

data[:items].map do |item|
  Contribution.new(
    id: item[:id],
    title: item[:title],
    url: item[:html_url],
    state: item[:state],
    type: item.has_key?(:pull_request) ? "pull_request" : "issue",
    created_at: item[:created_at],
    updated_at: item[:updated_at]
  )
end

One deliberate decision was to remove the GITHUB_TOKEN from the request entirely to avoid the possibility of leaking private information. I have access to a lot of repos and I wouldn’t want to accidentally leak something that shouldn’t be. Fortunately, GitHub allows enough public access without a token.

You can see the full implementation on GitHub.

Pulling in an RSS feed

This one is relatively straightforward. My blog has an Atom feed and we can pull that in and fetch the top five posts. I used Feedjira, which seems to be a well maintained RSS/Atom library which is able to handle malformed feeds well to avoid other problems:

client = Excon.new("https://nickcharlton.net/atom.xml")

response = client.get
feed = Feedjira.parse(response.body)

feed.entries.first(count).map do |entry|
  Contribution.new(
  id: entry.id,
  title: entry.title,
  url: entry.links.first,
  state: nil,
  type: nil,
  created_at: entry.published,
  updated_at: entry.updated,
  )
end

I figured re-using the Contribution object would be good enough — maybe it’d be nice to make the GitHub contribution specific fields default to nil but this will do.

You can see the full implementation on GitHub.

Assembling the README

This was much more fun to put together. In Simon’s original, he used an HTML comment to surround a block to replace and I did the same. So we’ll have the following in the README.md:

<!-- contributions starts -->
<!-- contributions ends -->

Elsewhere, we’ll assemble the line from the list of contributions and then we can replace the text:

replacement = <<~REPLACEMENT
  <!-- #{marker} starts -->
  #{content}
  <!-- #{marker} ends -->
REPLACEMENT

document.gsub(
  /<!\-\- #{marker} starts \-\->.*<!\-\- #{marker} ends \-\->/m,
  replacement.chomp
)

We’re able to use the marker to use the same code to update multiple sections, which is nice. You can see the full implementation on GitHub.

Building using GitHub Actions

GitHub Actions has a bunch of nice features we can use here. To start with, Actions are able to make changes to the repository that they’re running on, so we can use the git commands without configuring more than the name and email address. Next, we can use the schedule event to regularly trigger an update. I chose daily at 11am — I don’t make enough contributions for any more to be beneficial. I also configured workflow_dispatch so that I could manually trigger it if I so desired.

I had a bit of a chicken and egg problem in doing this. I wanted to test the Action on push to make sure it was working, but didn’t want the README changes to get picked up when merging. I ended up making sure it works, then deleting that commit, removing the push trigger and running it manually after. Anyway, here’s how it works:

---
name: Update README

on:
  workflow_dispatch:
  schedule:
    - cron: '0 11 * * *'

jobs:
  update:
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v2
    - name: Set up Ruby
      uses: ruby/setup-ruby@v1
      with:
        bundler-cache: true
    - name: Install dependencies
      run: bundle install
    - name: Update README
      run: |-
        bin/update_readme
        cat README.md
    - name: Commit and push if changed
      run: |-
        git diff
        git config --global user.email "actions@users.noreply.github.com"
        git config --global user.name "README Bot"
        git add -A
        git commit -m "Updated content" || exit 0
        git push

So now I have an auto-updating GitHub README which shows up on my profile. Go take a look!