Post to Mastodon on Commit with GitHub Actions

In a previous post, I explained how to automatically tweet when you add a new post to your blog using GitHub Actions and a custom commit message.

Now, I’ll show you how to post (or toot as the case may be) to Mastodon either with a post image or without using the same technique, albeit a little refined since my previous post.

Prerequisites

Before we go into detail, this post assumes the following:


  • you have a blog with static Markdown or MDX posts in a single directory (e.g. Next.js or Astro or Eleventy)
  • you are publishing new posts to your blog via GitHub commits
  • your posts are using either frontmatter or MDX meta values
  • your frontmatter or meta contains a publishDate or something similar
  • you have an active Mastodon account

Lastly, you’ll need to create a Mastodon app and grab your Mastodon auth token. You can do that once logged in to your Mastodon account by clicking Preferences, then Development, then New Application. Ensure that your app has at least read + write permission scopes. Copy down your keys and authorization token as we will need that later.

screenshot of Mastodon app creation screen

Overview

What we’re building here is a GitHub Action to run a node script which is triggered on a commit to your blog or site’s repo when certain words are in the commit message. In my case, I’m using mastodon post but you can use whatever you like. The script pulls the latest post by checking the publishDate in the frontmatter and then sends our Toot.

Setting it up this way means you can update your blog or site and only toot to Mastodon when you are ready.

Without further ado, here is the GitHub Action:

name: Mastodon Post
on: [push]
defaults:
    run:
        working-directory: mastodon
jobs:
    build:
        runs-on: ubuntu-latest
        if: "contains(github.event.head_commit.message, 'mastodon post')"
        steps:
            - name: Checkout repository
              uses: actions/checkout@v3
            - name: Setup Node.js
              uses: actions/setup-node@v3
              with:
                  node-version: 16
            - name: Install mastodon
              run: yarn add 'mastodon'
            - name: Install glob
              run: yarn add 'glob'
            - name: Install path
              run: yarn add 'path'
            - name: Install gray matter
              run: yarn add 'gray-matter'
            - name: Install Node modules
              run: yarn
            - name: Toot message
              run: node toot.cjs
              env:
                  MASTODON_AUTH_TOKEN: ${{ secrets.MASTODON_AUTH_TOKEN }}

To use this action, add a .github/workflows directory to your local repo and save the code above to toot.yml in that directory.

Alternatively, you can create the workflow on GitHub ([your repo] > Actions and pull the changes to your local repo.

What does the custom Action do?

The custom Action does the following:

  • defines the event to trigger the action [push] (e.g. on commit)
  • sets the working directory which in my case is a top-level /mastodon directory. This is where our node file lives and where we will install our node dependencies that we need to run that file
  • sets a conditional to only run if we have the text "mastodon post" in our commit message 👍
  • installs node using the actions/checkout@v3 action
  • installs our dependencies for our node file; note: I’ve had to separate out each dependency for it to work.
  • runs our node file toot.cjs, passing in our env secret from GitHub (in this case our MASTODON_AUTH_TOKEN); note: GitHub Actions requires common js use the .cjs extension…if you are using ES modules, you can continute to use .js.

Here is how my file structure is set up so that each post is its own directory along with associated files:

.
└── src/
    └── pages/
        └── words/
            ├── my-first-post.mdx

            └── my-second-post.mdx
    └── assets/
        └── images/
            ├── my-first-post.jpg

            └── my-second-post.jpg

# more stuffs
#

I’m using .mdx files for the post content but this would work with regular Markdown files as well. Note that the images have the exact same name as the posts, just with a different extension. This is how we will match up the correct images with the posts in our script.

Setup

Add the API keys + tokens generated from your Twitter app to your GitHub repo as environment variables in [repo] > Settings > Secrets like so:

GitHub Repository secrets

Note: make sure your Mastodon developer app has Read + Write permissions.

Next, add a top-level /mastodon folder to your repo and add a toot.js file in that directory.

cd into /mastodon and then add the dependencies we will need:

yarn add fs glob path mastodon gray-matter

Let’s go through the packages we are adding in detail:

  • fs: node file handling utilities
  • glob: allows for relative path search/matching with more complex regex patterns
  • path: node file path utility
  • mastodon: Mastodon API wrapper for node; we’ll use this to send our Toot
  • gray-matter: a smarter YAML parser (perfect for frontmatter)

Tip: if you are using a meta object in your .mdx files instead of frontmatter, you’ll want to install extract-mdx-metadata. My previous post has more details on using MDX metadata so check that if you need to.

Another note: remember to add your local /mastodon/node_modules to .gitignore as the GitHub Action will install these for us.

Example frontmatter

Here is an example of the frontmatter in a post .mdx file:

---
title: 'Post to Mastodon on Commit with GitHub Actions'
blurb: false
publishDate: '2022-11-26T13:35:13-06:00'
postImage: '../../images/posts/post-to-mastodon-on-commit-with-github-actions.jpg'
postImageAlt: 'GitHub Actions logo with post title'
excerpt: "<p>Post a link to a new blog post to Mastodon on commit using GitHub Actions. You can post with a post image for a long-form post or without for just a blurb. Here's how.</p>"
---

Node script

With our post ready and our frontmatter added, we can write our toot.cjs script to send the Toot to Mastodon with an image. Note that this was copied from earlier tweet code so you can sub in toot if you wish.

Here is the code in full:

// add required deps
const Masto = require('mastodon')
const fs = require('fs')
const glob = require('glob')
const { resolve, join, path } = require('path')
const matter = require('gray-matter')

// grab our token to make API requests
const { MASTODON_AUTH_TOKEN } = process.env

// initialize our Mastodon API client
const client = new Masto({
    access_token: MASTODON_AUTH_TOKEN,
    timeout_ms: 60 * 1000, // optional HTTP request timeout to apply to all requests.
})

// Get our posts and find the latest one
const getAllPosts = () => {

    let latestFile
    let sortedFiles = []
    let metaData

    // use glob and cwd (current working directory) to grab all of our posts
    glob(
        '**/*.mdx',
        { cwd: '../src/pages/words/' },
        async function (er, files) {
            const filesArray = (array) => {
                const promises = array.map(async (file) => {
                    const content = fs.readFileSync(
                        resolve(join('../src/pages/words/', file))
                    )
                    return {
                        file: file,
                        meta: await matter(content),
                    }
                })
                return Promise.all(promises)
            }

            const allFiles = await filesArray(files)

            let filesToSort = allFiles.map((file) => {
                const time = new Date(file.meta.data.publishDate)
                return {
                    file: file,
                    time: time,
                }
            })

            latestFile = getLatestFile(filesToSort) // getLatesFile() is below

            metaData = async (latestFile) => {
                const filePath = '../src/pages/words/' + latestFile.file.file
                const n = filePath.lastIndexOf('/')
                const latest = filePath.substring(n + 1)
                const imageDir = '../src/assets/images/posts/' // change this to your image dir
                const content = fs.readFileSync(filePath)
                const meta = await matter(content)
                main(meta, imageDir, latest)
            }

            if (latestFile) {
                return metaData(latestFile)
            }
        }
    )

    function getLatestFile(array) {
        array.sort((a, b) => b.time.getTime() - a.time.getTime())
        return array[0]
    }

    return null
}

getAllPosts()

/**
 * Post a tweet to account.
 *
 * @example
 * const tweet = await Twitter.post('This is an update', res.media.media_id_string);
 * // returns success/error object
 *
 * @param  {String} tweet  Tweet string
 * @param  {Twitter} client Client instance of Twitter package
 * @return {Promise<ClientResponse>} Return error or success object
 */
const post = (tweet, dir, postPath) => {
    console.log('tweet', tweet, dir, postPath)

    const limit = 277

    let post

    // if our post is a blurb, the post content is the content
    if (tweet.data.blurb && tweet.data.blurb === true) {
        post = tweet.content
    } else {
        post =
            'New post: ' +
            tweet.data.title +
            ' https://joshuaiz.com/words/' +
            postPath.replace('.mdx', '')
    }

    const tweetImageAltText = tweet.data.postImageAlt
        ? tweet.data.postImageAlt
        : tweet.data.title

    // ensure Tweet is correct length, but if not let's truncate
    // and still post.
    const tweetSubstr =
        post.length > limit ? `${post.substring(0, limit - 3)}...` : post

    const data = {
        status: tweetSubstr,
    }

    // post a tweet with media
    let imagePath
    let b64content

    if (tweet.data.blurb && tweet.data.blurb === true) {
        imagePath = dir + 'blurb.png'
    } else {
        imagePath = postPath.replace('.mdx', '.jpg')
        b64content = fs.readFileSync(resolve(join(dir + imagePath)), {
            encoding: 'base64',
        })
    }

    // if our post is a blurb, just send without image
    if (tweet.data.blurb && tweet.data.blurb === true) {
        return client.post(
            'statuses',
            { status: post },
            function (err, data, response) {
                console.log('tweet blurb data', data)
            }
        )
    } else {
        // use the client to post the message with image
        return client.post(
            'media',
            { file: fs.createReadStream(dir + imagePath) },
            function (err, data, response) {
                // now we can assign alt text to the media, for use by screen readers and
                // other text-based presentations and interpreters
                console.log('toot data', data)
                // console.log('toot response', response)
                const mediaIdStr = data.id
                const altText = tweetImageAltText
                const meta_params = {
                    media_id: mediaIdStr,
                    alt_text: { text: altText },
                }

                const params = {
                    status: post,
                    media_ids: [mediaIdStr],
                }

                client.post('statuses', params, function (err, data, response) {
                    console.log('tweet post data', data)
                })


            }
        )
    }
}

const main = async (tweet, dir, postPath) => {
    try {
        console.log('Attempting to post')
        await post(tweet, dir, postPath)
        console.log('Posted!')
    } catch (err) {
        console.error(err)
    }
}

The bulk of the above script comes from the Twit docs but the new bit is the getAllPosts() function. The node-mastodon package is actually a fork of twit and uses the same commands.

What the getAllPosts() function does is uses glob patterns to grab all the paths to the posts in your posts directory. Be sure to change wherever it says ../your/posts/directory to your actual posts directory. We use the cwd (current working directory) as a starting point so we can then use relative paths.

Getting the correct paths was the hardest part to figure out as we need relative paths so the script works both locally and on GitHub.

Once we have our files (posts) list, we can loop through them to get each post’s publishDate from the post’s frontmatter using the gray-matter matter function.

Note that this glob function returns just the .mdx file path within the posts directory, e.g. my-first-post.mdx so that is what is added to the file value for each item in the sortedFiles array.

With the filesToSort array, we can use a simple function to sort by our metadata date stamp and then return the latest .mdx file’s path. We now have the path to the latest created file in our specified posts directory. Sweetness!

Armed with that path, we can move on to grabbing the metadata we will need for the content of our Tweet.

To refresh, here’s sample frontmatter from our post:

---
title: 'Post to Mastodon on Commit with GitHub Actions'
blurb: false
publishDate: '2022-11-26T13:35:13-06:00'
postImage: '../../images/posts/posting-to-mastodon-on-commit-with-github-actions.jpg'
postImageAlt: 'GitHub Actions logo with post title'
excerpt: "<p>Post a link to a new blog post to Mastodon on commit using GitHub Actions. You can post with a post image for a long-form post or without for just a blurb. Here's how.</p>"
---

In the metaData async function, we can reconstruct our full file path with this:

const filePath = '../your/posts/directory/' + latestFile.file.file

E.g. ../your/posts/directory/my-first-post.mdx

We also want to grab the post directory which is also the post slug like so:

const n = filePath.lastIndexOf('/')
const latest = filePath.substring(n + 1)

We then save the post’s content to a variable with:

// get post content
const content = fs.readFileSync(filePath)

And with the content we can now extract our metadata to use for our Tweet using extract-mdx-metadata:

// extract frontmatter from content
const meta = await matter(content)

We send that along with our post directory postDir to our main function which calls the post function to parse the metadata into the content + image for our Tweet. Alternatively, if we are posting just a blurb, it uses the post content without an image.

In this case we are using the frontmatter title but you could use any value from your posts’ frontmatter.

We create our post (Toot content) with the post title and a link to the post.

With our dir (post directory) we can also reconstruct the path to our post image with the same slug as our post and then base64 encode that to upload to Mastodon.

From there, the node-mastodon post function uploads our image and then an additional post call using 'media matches the Toot content status: post to our image.

And that’s it.

Usage

To use, once you have your post ready, just send a commit to your repo with "mastodon post" anywhere in your commit message:

git commit -m "mastodon post My first post to Mastodon!"

This would also work:

git commit -m "This is a mastodon post"

Sorted. You can now post to Mastodon whenever you have a new blog post automagically on commit!

Conclusion

I hope this post was helpful. If you have any questions, you can find me on Mastodon: @joshuaiz.