Share latest post automatically in Twitter using GitHub Actions

Utilizing GitHub Actions Path Filter and CLI Composition

  • Published at 
  • 4 minutes reading time

I'm inherently lazy, especially when it comes to menial tasks. Sharing my blog post to twitter after publishing it is not an exception. In the spirit of overengineering my blog platform, I decided to build an automation that handle this routine process for me. As all my posts are written in markdown and stored in a repo in GitHub, I guess using GitHub Actions makes sense. I've used Actions before with great success. This time, instead of using scheduling feature, I'm using commit hook.


The workflow of me writing a blog post consist of creating new branch and making pull request to master branch. 2 benefits that I get with this workflow are: I can proofread in custom preview URL before publishing, and when I merge to master it's automatically aliased to my primary domain. After it's merged, I open my site, click my latest article, copy the url from address bar, open twitter, add some caption and share it with the world.

It's a quick, predictable, yet annoying step that I have to do every time I write an article. Granted, I haven't write a lot of articles, but it's such a chore. Having worked on a lot of automation in the past (both in work and personal setting), I keep thinking on how to eliminate this last step entirely.

As GitHub actions matured and more work projects start using it, it's clear to me how I can solve this in a nice way. It's also an interesting challenge that can be published as another blog post. Win-win.

One great thing about GitHub actions is you can create a hook whenever a commit landed in a branch that matches some paths. I emphasized the last part because it's such a good feature, we're also using it to create automated NPM package publish workflow in our monorepo setup at work.

# .github/workflows/share_social.yml
name: Share latest post in social media
on:
push:
branches:
- master
paths:
- 'posts/**'

This means I can ignore any commit to master branch that doesn't contain diff to the post content. My script can always assume there's a change in the posts directory. It also runs less often which saves costs.

Scripting Time

Next step is deciding on how to only post the latest article, as changes to the posts directory can also contain removal / edit to existing post. To do this, we're going to create a shell script that starts with git diff. You can see changes in latest commit by using git diff HEAD~1. We only care about change type (addition, deletion, etc) not the content itself, so we can filter it using --name-status flag which gives us both change type and file name.

⬢ v12.14.1 ❯ git diff --name-status HEAD~1
A posts/2020-04-09-twitter-github-actions.md
M yarn.lock

A means addition / new file, M is modified/edited, and D means deletion/removal.

We can use grep to filter addition of markdown files. I decided to use 2 grep instead of using complex regex inside single grep. The first grep is used to filter all file that ends with .md extension, and we use the second grep to filter all addition which starts with A.

It looks something like this:

⬢ v12.14.1 ❯ git diff --name-status HEAD~1 | grep ".md$" | grep "^A"
A posts/2020-04-09-twitter-github-actions.md

As you can see we still have some additional character left that we need to remove in order to get file path to the new file. I'm not going to pretend I know what I'm doing here, I just keep trying to pipe it through cut -c until I get the character I needed. Turns out it's cut -c 3-:

⬢ v12.14.1 ❯ git diff --name-status HEAD~1 | grep ".md$" | grep "^A" | cut -c 3-
posts/2020-04-09-twitter-github-actions.md

The last step is create a custom CLI that accept an argument of file path that share the link of the new post to twitter. We can use xargs to pipe the path to the CLI, and the command in full becomes:

⬢ v12.14.1 ❯ git diff --name-status HEAD~1 | grep ".md$" | grep "^A" | cut -c 3- | xargs yarn share
# it's equivalent with running this command:
# yarn share posts/2020-04-09-twitter-github-actions.md

TypeScripting Time

Because my in-house markdown parser is written in typescript, it's easier for me to also write the CLI in TypeScript. I can import the parser, get the title of the post and share it to twitter. I use twitter-lite library because it's pretty simple. I can create Twitter integration and use my own access token / secret

This is the source code minus few validations and error handling:

// packages/trail/index.ts
import fs from 'fs';
import path from 'path';
import parse from '@paper/mill';
import Twitter from 'twitter-lite';
const client = new Twitter({
subdomain: 'api',
version: '1.1',
consumer_key: process.env.TWITTER_CONSUMER_KEY,
consumer_secret: process.env.TWITTER_CONSUMER_SECRET,
access_token_key: process.env.TWITTER_ACCESS_TOKEN_KEY,
access_token_secret: process.env.TWITTER_ACCESS_TOKEN_SECRET,
});
// posts/YYYY-MM-DD-post-slug.md
const filename = process.argv[2];
const [year, month, date, ...slugs] = filename
.replace('posts/', '')
.replace('.md', '')
.split('-');
const postPath = path.join(__dirname, '../..', filename);
const markdown = (await fs.promises.readFile(postPath, 'utf-8')) as string;
const post = await parse(markdown);
const caption = post.frontmatter.socialCaption ?? `New Post: ${post.title}`;
const status = `${caption} https://fatihkalifa.com/${slugs.join('-')}`;
await client.post('statuses/update', { status });

As you can see it's pretty straightforward. By default it uses post title as a caption or I can optionally add custom caption using front matter.

Additional note for GitHub Actions

You can use GitHub secrets feature to store key value pair of sensitife data (usually token & secret pair) associated in specific repository. You can expose these secrets as environment variable but you need to do this explicitly because by default no secrets are automatically exposed as environment variable.

In this case I have 4 twitter secret keys to create new tweet and 1 unsplash access key for retrieving unsplash photo metadata.

jobs:
build:
runs-on: ubuntu-latest
steps:
# bla bla bla checkout steps
# and install steps
- name: Share status
working-directory: packages/trail
env:
TWITTER_ACCESS_TOKEN_KEY: ${{ secrets.TWITTER_ACCESS_TOKEN_KEY }}
TWITTER_ACCESS_TOKEN_SECRET: ${{ secrets.TWITTER_ACCESS_TOKEN_SECRET }}
TWITTER_CONSUMER_KEY: ${{ secrets.TWITTER_CONSUMER_KEY }}
TWITTER_CONSUMER_SECRET: ${{ secrets.TWITTER_CONSUMER_SECRET }}
UNSPLASH_ACCESS_KEY: ${{ secrets.UNSPLASH_ACCESS_KEY }}
run: git diff --name-status HEAD~1 | grep ".md$" | grep grep "^A" | cut -c 3- | xargs yarn share

You use ${{ secrets.NAME }} string interpolation because your secret might contains unsafe characters.


In the future I'll probably add linkedin to list of social media platform to post to as these two platforms align with the content of my blog, but for now twitter is enough.

Webmentions