h1 {
      font-size: 2.5em; /* Adjust to desired size */
  }
  
  h2 {
      font-size: 1.5em; /* Adjust to desired size */
  }
  
  h3 {
      font-size: 1.2em; /* Adjust to desired size */
  }

First post of 2025!

I ended 2024 with my post, pydantic-xml: Parsing My RSS Feed, and mentioned that I was trying to add my most recent blog post to my GitHub profile using a custom GitHub action and workflow. I'm happy to share that after a couple of weeks, I figured it out. 😎

I won't bore you with every struggle I experienced. Instead, I'll share what files I had to create, how they interact with each other from a high level, and then give a (mostly) line-by-line breakdown of the important stuff.

The files

All together, I created four new files and modified two others:

| | File | |----------|------------------------------------------------------------------| | New | action.ymlDockerfilerecent-posts.ymlrequirements.txt | | Modified | main.pyREADME.md |

The files are across two repos, my blog repo and my profile repo. This organizes things and allows me to keep the action in the blog repo, and the workflow in the profile repo.

Here is the layout of my two repos.

📂 it176131.github.io  # blog repo
└── 📂 .github
    └── 📂 actions
        └── 📂 recent-posts
            ├── 🔧 action.yml  # The action metadata file
            ├── 🐋 Dockerfile  # Instructions to build the Docker container
            ├── 🐍 main.py  # Python script to update the input (README.md)
            └── 📄 requirements.txt  # Python package dependencies for main.py

📂 it176131  # profile repo
├── 📂 .github
│   └── 📂 workflows
│       └── 🔧 recent-posts.yml  # The workflow file
└── 📓 README.md  # My "profile"

The interactions

There are six interactions among the files that result in the README.md being updated. Because the files have unique names, I will reference them as if they were local to each other. For example, I will reference it176131/.github/workflows/recent-posts.yml as recent-posts.yml.

For your convenience, each file name will have a tooltip ℹ️ with the full path so you can keep them straight 😉.

  1. recent-posts.yml ℹ️ checks out the repository, giving it access to README.md ℹ️.
  2. recent-posts.yml ℹ️ calls action.yml ℹ️ with inputs readme (default README.md ℹ️) and num-entries (default 5).
  3. action.yml ℹ️ informs GitHub to build a Docker container using the Dockerfile ℹ️.
  4. The Docker container produced by Dockerfile ℹ️ installs the packages in requirements.txt ℹ️ and runs main.py ℹ️ with the inputs from step 1.
  5. main.py ℹ️ takes the inputs and updates the README.md ℹ️ with the latest posts.
  6. recent-posts.yml ℹ️ checks if README.md ℹ️ has been modified, commiting and pushing any changes.

That's quite a bit of interaction. Let's open up the files and see what's going on under the hood.

The breakdown

I'll start with recent-posts.yml ℹ️ as it's the first file referenced in step 1.

recent-posts.yml

Some meta-information

The recent-posts.yml ℹ️ is a workflow file. It has an optional name to help identify it in the GitHub UI.

name: "Update README with most recent blog post"

|------------------------------------------------|---------------------------------------------| | Workflow Name in GitHub UI 1 |Workflow Name in GitHub UI 2|

Workflow files run when triggered by an event. Those "events" are defined via the required on keyword.

on:

This particular workflow has two types of triggering events:

  • a schedule that runs every five minutes (though I'll probably change that),
   schedule:
    - cron: "* * * * *"
  push:
    branches: ["main", "master"]

They are combined under the same on:

on:
  push:
    branches: ["main", "master"]

  schedule:
     - cron: "* * * * *"

When a workflow is triggered, it has to do one or more things. Those things are called jobs. This workflow has one job with the <job_id>, recent_post_job. It has the more human-readable name, "Recent Post," which is what we see in the GitHub UI,

|-------------------------------------------|---------------------------------| | Job Name in GitHub UI 1 | Job Name in GitHub UI 2|

and it runs-on the latest version of Ubuntu.

jobs:
  recent_post_job:
    runs-on: ubuntu-latest
    name: Recent Post

Now for the moment we've all been waiting for...

Step 1

recent-posts.yml ℹ️ checks out the repository, giving it access to README.md ℹ️.

Each step under our recent_post_job has a name, and step 1's name is "Checkout repo."

To add, delete, modify, and even read a file in the repository requires it to be checked out. Step 1 uses the GitHub action actions/checkout@v4 to do this. I won't get to in the weeds here, but if you don't do this step, you won't be able to do anything with the README.md.

jobs:
  recent_post_job:
    runs-on: ubuntu-latest
    name: Recent Post
    steps:
      - name: Checkout repo
        uses: actions/checkout@v4

Step 2

recent-posts.yml ℹ️ calls action.yml ℹ️ with inputs readme (default README.md ℹ️) and num-entries (default 5).

This is where we leave my profile repo for my blog repo.

The "Recent post action" step uses the action, it176131/it176131.github.io/.github/actions/recent-posts@main, from my blog repo with the inputs:

  • readme: "./README.md"
  • num-entries: 5

[!NOTE]

Order is important here for a reason you'll see in step 5.

jobs:
  recent_post_job:
#     ...
    steps:
#      ...

      - name: Recent post action
        uses: "it176131/it176131.github.io/.github/actions/recent-posts@main"
        with:
          readme: "./README.md"
          num-entries: 5

action.yml

Some (more) meta-information

Calling an action requires an action.yml file. It contains both general information about the action and instructions for GitHub to run it.

This particular action's name is "Recent Posts". It has the description, "Get the most recent blog post metadata," and an optional author, yours truly.

name: "Recent Posts"
author: "Ian Thompson"
description: "Get the most recent blog post metadata."

It defines the expected inputs with an <input_id>, and provides a description, default value, and whether it's required.

inputs:
  readme:
    description: "Path to the README.md"
    required: false
    default: "./README.md"

  num-entries:
    description: "Number of blog entries to show"
    required: false
    default: 5

[!NOTE]

I declared both of my inputs as optional, i.e. required: false. If I removed the default values I'd have to change them to required: true.

Step 3

action.yml ℹ️ informs GitHub to build a Docker container using the Dockerfile ℹ️.

This last part of the action.yml tells GitHub what kind of action it runs. We're using Docker and the instructions to build the container are in our DockerFile ℹ️ image. As the container is starting, GitHub passes the args from our recent-posts.yml ℹ️, or the defaults if we hadn't passed any.

runs:
  using: "docker"
  image: "Dockerfile"
  args:
    - ${{ inputs.readme }}
    - ${{ inputs.num-entries }}

Dockerfile

Step 4

The Docker container produced by Dockerfile ℹ️ installs the packages in requirements.txt ℹ️ and runs main.py ℹ️ with the inputs from step 1.

The Docker container produced via the DockerFile ℹ️ comes FROM a Python 3.13 base image.

FROM python:3.13

It then makes a COPY of the requirements.txt ℹ️ file to its primary directory.

COPY requirements.txt ./

From here it will RUN the pip install command on the newly copied requirements.txt, satisfying the dependencies of main.py ℹ️.

RUN pip install --no-cache-dir -r requirements.txt \

After that, it will copy the rest of the files in the same directory as DockerFile ℹ️ to the container directory, before running main.py ℹ️ as an ENTRYPOINT.

COPY . ./

ENTRYPOINT ["python", "/main.py"]

[!NOTE]

Running the Python script as an entrypoint is required if you want main.py ℹ️ to receive the args from action.yml ℹ️ and recent-posts.yml ℹ️.

main.py

I'm not going to walk through the main.py ℹ️ file line-by-line as I covered most of it in my previous post. However, I will note that I made some changes so that it can directly modify the README.md ℹ️ file. I will walk through what I consider the most important parts.

Some changes required me to update my import statements.

 from datetime import datetime
-from typing import Final
+import re
+from typing import Annotated, Final

 import httpx
 from httpx import Response
 from pydantic.networks import HttpUrl
+from pydantic.types import FilePath
 from pydantic_xml.model import (
     attr, BaseXmlModel, computed_element, element, wrapped
 )
-from rich.console import Console
+from typer import Typer
+from typer.params import Argument

 BLOG_URL = "https://it176131.github.io"
 NSMAP: Final[dict[str, str]] = {"": "http://www.w3.org/2005/Atom"}
+app = Typer()

I also decided that I wanted more than the single most recent blog post, so I changed Feed.entry to Feed.entries.

 class Feed(BaseXmlModel, tag="feed", nsmap=NSMAP, search_mode="ordered"):
     """Validate the RSS feed/XML from my blog."""

-    # We limit to the first <entry> from the RSS feed as it is the most
-    # recently published.
-    entry: Entry
+    # We collect all <entry> tags from the RSS feed.
+    entries: list[Entry]

Step 5

main.py ℹ️ takes the inputs and updates the README.md ℹ️ with the latest posts.

To accept the inputs, I needed to modify my script's signature with both a readme and num_entries parameter.

-if __name__ == "__main__":
+@app.command()
+def main(
+        readme: Annotated[
+            FilePath,
+            Argument(help="Path to file where metadata will be written.")
+        ],
+        num_entries: Annotated[
+            int,
+            Argument(help="Number of blog entries to write to the `readme`.")
+        ],
+) -> None:
+    """Write most recent blog post metadata to ``readme``."""

The num_entries argument allowed me to dynamically control how many blog posts to get from my Feed.entries list.

     resp: Response = httpx.get(url=f"{BLOG_URL}/feed.xml")
     xml: bytes = resp.content
-    console = Console()
     model = Feed.from_xml(source=xml)
-    console.print(model.model_dump_json(indent=2))
+    entries = model.entries[:num_entries]
+

Modifying the README.md ℹ️ was a bit more involved. First I had to read the file in. Then, with the help of a regular expression, I could find the text between two HTML comments and replace it with my entries. After that I could overwrite the README.md ℹ️ and my work would be done.

+    with readme.open(mode="r") as f:
+        text = f.read()
+
+    pattern = r"(?<=<!-- BLOG START -->)[\S\s]*(?=<!-- BLOG END -->)"
+    template = "- [{title}]({link}) by {author}"
+    repl = "\n".join(
+        [
+            template.format(title=e.title, link=e.link, author=e.author)
+            for e in entries
+        ]
+    )
+    new_text = re.sub(pattern=pattern, repl=f"\n{repl}\n", string=text)
+    with readme.open(mode="w") as f:
+        f.write(new_text)
+
+
+if __name__ == "__main__":
+    app()

README.md

Using a regular expression only works if the HTML comments already exist, which they didn't at first. This meant that I had to modify the README.md ℹ️ before my workflow could do anything.

In a previous version of my README.md ℹ️ I had a section called "Articles" that originally held links to my Medium content. I don't really write on Medium anymore, and figured this would be a good place to put the output of my workflow.

 # Articles ✍
-![Medium](https://github-read-medium-git-main.pahlevikun.vercel.app/latest?username=ianiat11&limit=6&theme=dracula)
+<!-- BLOG START -->
+<!-- BLOG END -->

Now my script has a place between two comments to write my blog entries 😎.

recent-posts.yml (revisited)

Step 6

recent-posts.yml ℹ️ checks if README.md ℹ️ has been modified, commiting and pushing any changes.

After main.py ℹ️ has finished, the action is complete, and we return to recent-posts.yml ℹ️ with the (possibly) modified README.md ℹ️.

The final step in the workflow is called "Commit README," and that's pretty much what it will run. First, git configures the user.email and user.name so I can tell when the action is performing a commit versus myself. Next, it determines if the README.md ℹ️ has actually been modified. If it has, it will add, commit, and push the changes. Otherwise, the step ends along with the workflow.

jobs:
  recent_post_job:
    runs-on: ubuntu-latest
    name: Recent Post
    steps:
#      ...

      - name: Commit README
        run: |
          git config user.email github-actions@github.com
          git config user.name github-actions
          has_diff=$(git diff main --name-only -- README.md)
          if [ $has_diff ]; then
            git add README.md
            git commit -m "Synced and updated with most recent it176131.github.io blog post"
            git push
          fi

Conclusion

And that's it! On the first run of this workflow there were obviously some changes to my README.md ℹ️:

# Articles ✍
<!-- BLOG START -->
+- [pydantic-xml: Parsing My RSS Feed](https://it176131.github.io/2024/12/23/pydantic-xml.html) by Ian Thompson
+- [isort + git: Cleaner Import Statements for Those Who Don’t Like pre-commit](https://it176131.github.io/2024/12/12/isort.html) by Ian Thompson
+- [PyCharm: Projects &amp; Environments](https://it176131.github.io/2024/12/03/pycharm-projects-envs.html) by Ian Thompson
+- [Dynamic Enums](https://it176131.github.io/2024/11/29/dynamic-enums.html) by Ian Thompson
+- [SpaCy: Extensions](https://it176131.github.io/2024/11/27/spacy-extensions.html) by Ian Thompson
<!-- BLOG END -->

I went a bit further and also updated the section title and moved my blog link from the top of my profile to the new section title.

 ### Hi there 👋 I'm Ian 🙂
-Checkout my blog! 👉 https://it176131.github.io/

 # Reputation ✔
 <a href="https://stackoverflow.com/users/6509519/ian-thompson"><img src="https://stackoverflow.com/users/flair/6509519.png?theme=dark" width="208" height="58" alt="profile for Ian
 Thompson at Stack Overflow, Q&amp;A for professional and enthusiast programmers" title="profile for Ian Thompson at Stack Overflow, Q&amp;A for professional and enthusiast programmers"></a>

-# Articles ✍
+# Recent Articles From My [Blog](https://it176131.github.io/) ✍
 <!-- BLOG START -->
 - [pydantic-xml: Parsing My RSS Feed](https://it176131.github.io/2024/12/23/pydantic-xml.html) by Ian Thompson
 - [isort + git: Cleaner Import Statements for Those Who Don’t Like pre-commit](https://it176131.github.io/2024/12/12/isort.html) by Ian Thompson

And after this entry is published, there will be an additional change.

I hope this has been informative and provides enough details on GitHub actions and workflows for you to create your own. Happy coding 🤓.