Crest
03 Aug 2025

Common lisp and Github workflows

Dynamically updating Github profile

github-profile-banner.png

Github has a functionality where the README file in a repository with the same name as your username will be displayed on your profile. Since I am a fan of the indieweb’s POSSE1 practice, I’d like to use that space to advertise the writings on this website.

Other than merely linking to it as a handle I can write a short program that dynamically updates the profile to include what my most recent posts are. This also gives me an opportunity to write another program in lisp, something that always brings me joy.

Creating the README

First we must create the actual README file that will be displayed. For this we create a short program in common lisp. The result should be a file named README.org2 with a short biographical text followed by the X most recent blog posts (I chose 3 arbitrarily).

First we fetch the drakma and xmls packages from quicklisp, the only two dependencies. We also create global variables that hold the link to the RSS feed being fetched, the permanent contents of the README, and the number of posts to display.

(ql:quickload '(:drakma :xmls))

(defvar *rss-url* "https://joarvarndt.se/rss.xml")

(defvar *template* "I am a university student currently studying political science
at the Swedish Defence University. I am interested in Lisp-languages, GNU Emacs,
Free software, political economy, and the philosophy of life in an
increasingly technical world.

Here are some recent blog posts of mine:
")

(defvar *article-count* 3)

Then we write a function that will find the titles of the three (or any other number) most recent entries in the URL of the RSS feed supplied. Each entry is formatted as a associated list (alist) with the first element being the title of the post, and the second being the link to it.

(defun fetch-recent-articles (url &optional (count 3))
  "Fetch the COUNT (default 3) most recent RSS feed entries from URL and return an alist
mapping title to link for each article."
  (let* ((response (drakma:http-request url))
         (xml-string (octets-to-string response))
         doc channel items)
    (setf doc (xmls:parse xml-string))
    (setf channel (first (xmls:xmlrep-find-child-tags "channel" doc)))
    (setf items (xmls:xmlrep-find-child-tags "item" channel))
    (loop for item in (subseq items 0 (min count (length items)))
          for title-node = (xmls:xmlrep-find-child-tag "title" item)
          for link-node  = (xmls:xmlrep-find-child-tag "link" item)
          for title = (xmls:xmlrep-string-child title-node)
          for link  = (xmls:xmlrep-string-child link-node)
          collect (cons title link))))

The titles are then formatted to a bullet list of org-mode titles and inserted after the contents of the template. The contents are then written to the README.org file, creating it if it doesn’t exist.

(defun format-org-links (articles)
  "Expects an alist of article titles and their links, and outputs
a list of strings formatted as org-mode links."
  (mapcar (lambda (pair)
            (let ((title (car pair))
                  (link (cdr pair)))
              (format t "- [[~a][~a]]" link title)
              (format nil "- [[~a][~a]]" link title)))
          articles))

(defun fill-in-template (template links)
  "Adds the list of LINKS to the end of TEMPLATE.
LINKS should be a list of org-mode link strings.
Returns the final string."
  (format nil "~a~%~{~a~^~%~}" template links))

(defun write-to-file (text)
  (with-open-file (str "./README.org"
                       :direction :output
                       :if-exists :supersede
                       :if-does-not-exist :create)
    (format str text)))

Finally, this is all carried out in a main function, with the fetched results being printed beforehand.

(defun main ()
  (let ((recent-articles (fetch-recent-articles
                          *rss-url*
                          *article-count*)))
    (format t "Recent article list: ~% ~A ~%" recent-articles)
    (write-to-file (fill-in-template *template*
                                     (format-org-links recent-articles)))))

(main)

Using Github workflows

This exercise also served to teach me to use Github’s “workflow” feature, a form of continous deployment. This way I also don’t have to maintain any hardware to constantly monitor the status of the blog posts.

First we give github write permissions to the repo and tell it to run the workflow twice a day and whenever I push to main (this was useful for troubleshooting).

name: Update Github Blog list

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
  schedule:
    - cron: "0 */12 * * *"

permissions:
  contents: write

Since this will always be a fresh machine we need to make sure that SBCL (Steel Bank Common Lisp) as well as the necessary dependencies are installed. This will also install Quicklisp, a package manager for common lisp libraries. Quicklisp usually requires you to confirm that it should add itself to the SBCL init file, but since this is an automated workflow we have to wrap (ql:add-to-init-file) in the (ql-util:without-promting) macro.

Installing SBCL accounts for the vast majority of the runtime, with installing drakma and xmls as the two smaller steps. Actually running main.lisp is almost instant.

jobs:
  run-app:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Install sbcl
        run: sudo apt-get update && sudo apt-get install -y sbcl

      - name: Install Quicklisp
        run: |
          curl -O https://beta.quicklisp.org/quicklisp.lisp
          sbcl --noinform --non-interactive \
               --load quicklisp.lisp \
               --eval '(quicklisp-quickstart:install)' \
               --eval '(ql-util:without-prompting (ql:add-to-init-file))' \
               --quit
               
      - name: Install Dependencies
        run: |
          sbcl --noinform --non-interactive \
               --eval '(ql:quickload "drakma")' \
               --eval '(ql:quickload "xmls")' \
               --quit

It then runs the common lisp application we wrote earlier and configures git. If git diff doesn’t detect any changes to the README it will finish successfully, otherwise the workflow will itself update the repository and push the changes to Github, displaying the changes publicly.

      - name: Update README
        run: |
          sbcl --load ~/.sbclrc --script main.lisp
          git config --local user.name "github-actions"
          git config --local user.email "github-actions@github.com"

      - name: Check for changes
        id: check_changes
        run: |
          git diff --quiet || echo "changed=true" >> $GITHUB_OUTPUT
          
      - name: Commit changes
        if: steps.check_changes.outputs.changed == 'true'
        run: |
          git add README.org
          git commit -m "Update README with latest blogs"
          git push
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

Conclusion

I’ve tried to make the code portable, so if you want to do the same thing all you have to do is put this code in your own repository, change the values of the global variables in main.lisp and it should work.

Footnotes:

1

Publish (on your) Own Site, Syndicate Elsewhere

2

I of course prefer to use org mode over markdown here, just to subtly showcase my love for it publicly.

Tags: technology