Tweeting New Jekyll Posts From Github Actions

This site is built with Jekyll and hosted on Amazon S3, with Cloudfront as the CDN. I recently did some work to make the deployments automated with Github Actions.

I wrote about that experience already, but the current workflow is:

  1. Every push to main is deployed to a staging environment (I don’t use pull requests)
  2. Deployments to production is handled through a github action workflow that is manually triggered

I previously used Blogger and WordPress to manage my blog and both of those had the option to automatically tweet about new posts, but there is no built-in mechanism to do that with Jekyll (if you’re self-hosting). Of course I could manually tweet about every new post, but what’s the fun in that?

Here is the workflow I envisioned:

  1. When I trigger the deployment to production some Github Action is triggered that checks for any new posts
  2. For every new post (usually just one), extract the title and URL of the post
  3. Tweet about it

How hard could it be? (Spoiler: very)

I read through this post by Dave Brock who had a similar goal, but he simply put his desired tweet as the commit message. I didn’t really like that approach - Jekyll already knows the post title and URL, and it’s all available in git - so I should be able to get that information and tweet it out.

The first idea I had was to start using Github releases to track every new ‘publication’ - meaning when I deploy to production a new release is created, and I can check for any new posts introduced between this latest release and the previous one. I started down that path but then I realized that it’s actually too heavy-handed for what I wanted - I’m really only using the existance of the tag.

I started off creating a new tag on every production deploy, so just extending my existing workflow:

- name: Create Release Tag
  env:
    GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
  run: |
    TAG_NAME=production-release-$(date +'%Y.%m.%d.%H%M')
    git tag "$TAG_NAME"
    git push origin "$TAG_NAME"

The GITHUB_TOKEN is automatically exposed by Github Actions, so that felt like I was on the right track. Instead of extending the production workflow further I decided to have a dedicated ‘tweet’ workflow that is triggered by the creation of a new tag.

on:
  push:
    tags:
      - 'production-release-*'

In order to test this new workflow I simply had it print out the newly created tag name. However, when I tested it the workflow never triggered. Apparently this is by design:

GitHub does not trigger a new workflow run when a tag is pushed using the default GITHUB_TOKEN (which you’re using via GH_TOKEN). This is a deliberate design choice to prevent infinite loops between workflows.

Not to worry, I did some more reading and discovered I can set the trigger for my tweet workflow to explicitly kick off when the production deployment workflow completes. That’s arguably even cleaner, since I’m not relying on side effect of the deployment - I’m relying on the deployment workflow itself. The only annoying bit is that I need to explicitly check if the workflow run was successful.

on:
  workflow_run:
    workflows: ["Deploy to production on manual trigger"]
    types:
      - completed
jobs:
  tweet:
    if: ${{ github.event.workflow_run.conclusion == 'success' }}
    runs-on: ubuntu-latest
    ...

I tested this and ran into an obscure error:

Error
No event triggers defined in `on`

Again, apparently this is a known issue (according to ChatGPT - I didn’t find this note in the documentation):

workflow_run only triggers on workflows that run in response to events like push, pull_request, schedule, etc. It does not trigger for workflows started manually via workflow_dispatch.

This is a known limitation in GitHub Actions: workflow_run does not support triggering on workflows that were manually triggered (workflow_dispatch).

I had the option of trying to use a repository_dispatch - which is manually calling a Github API to trigger the workflow via an HTTP API - or simply adding the tweet steps to the existing workflow. Since having a separate workflow wasn’t particularly important I opted to try that first.

At this point I needed to parse the current and previous tags, determine if there was a new post in the changeset, extract the title and URL of the post(s), and invoke the proper twitter API. The git part of this was reasonably straightforward after I read through some of the documentation:

- name: Get previous production release tag
  id: calculate_previous_release_tag
  run: |
    TAG=$(git tag --list 'production-release-*' --sort=-creatordate --no-contains | head -n 1)
    echo "previous_release_tag=$TAG" >> $GITHUB_OUTPUT

- name: Identify new published post
  id: identify_new_post
  run: |
    CURRENT_TAG=${{ github.ref_name }}
    PREVIOUS_TAG=${{ steps.calculate_previous_release_tag.outputs.previous_release_tag }}

    echo "The new release tag is: $CURRENT_TAG"
    echo "The previous release tag is: $PREVIOUS_TAG"

    POST_PATH=$(git diff --name-only --diff-filter=A "$PREVIOUS_TAG" "$CURRENT_TAG" -- _posts/ | head -n 1)
    if [ -n "$POST_PATH" ]; then
      echo "Found new post: $POST_PATH"
      echo "post_path=$POST_PATH" >> $GITHUB_OUTPUT
    fi

Extracting the title and URL of the post was not that straightforward. I was hoping there was a jekyll command to get that metadata, but nothing like that exists (although I’m sure you could write a plugin to do that nicely). I started to do this with a bunch of bash scripts but it felt super shaky, so I wrote a simple ruby script instead.

content = File.read(options[:post_path])
post_metadata = YAML.safe_load(content.split(/^---\s*$/, 3)[1])

if options[:extract] == 'title'
  raise "#{options[:post_path]} does not contain a title" unless post_metadata.key?('title')

  puts post_metadata['title']
else
  raise "#{options[:post_path]} does not contain a permalink" unless post_metadata.key?('permalink')

  site_metadata = YAML.safe_load(File.read('_config.yml'))
  puts URI.join(site_metadata['url'], post_metadata['permalink'])
end

This script also feels a bit shaky, but less so than having to write bash to remove comments out of a YAML file. At this point I finally had the title and URL of the newly added post, all extracted from the git history. All that was left was to use the Send Tweet Action in Github Actions.

- name: Tweet the new post
  if: steps.identify_new_post.outputs.post_path != ''
  uses: ethomson/send-tweet-action@v1
  with:
    status: "🚀 Blogged: ${{ steps.extract_post_info.outputs.post_title }}\n\n${{ steps.extract_post_info.outputs.post_url }}"
  env:
    TWITTER_API_KEY: ${{ secrets.TWITTER_API_KEY }}
    TWITTER_API_SECRET: ${{ secrets.TWITTER_API_SECRET }}
    TWITTER_ACCESS_TOKEN: ${{ secrets.TWITTER_ACCESS_TOKEN }}
    TWITTER_ACCESS_TOKEN_SECRET: ${{ secrets.TWITTER_ACCESS_TOKEN_SECRET }}

And at this point I decided I really hate this entire solution: it’s way too complicated. I think the approach used by Dave Brock is actually much better - I can simply write commit messages with a certain format and invoke the Send Tweet Action as part of production releases if that format is present. I can easily write a ruby script to write the commit message which will be much easier to test and debug.