Implementing a private JavaScript GitHub action
In my quest to automate announcing of new blog posts to social media using GitHub Actions I managed to find existing actions for most steps in the process. However, the act of composing the final message from the collected data was just too specific to my needs and there was no way around writing some custom code. I ended up creating a private JavaScript action for that.
This approach is a good fit for me because:
- It allows me to keep the code I want to execute as part of the workflow in the same repository. This makes sense since I can't imagine reusing the same code somewhere else. It's just too specific to the task at hand.
- It requires minimum ceremony to get my custom code running. Even more so, since I already need Node.js to build my blog before deployment.
I followed the official guidance and placed my action in a subfolder of .github/actions
. Inside that folder I had to:
- Initialize
npm
, i.e., create apackage.json
file:npm init -y
- Create an
action.yml
metadata file, most importantly describing the action inputs and outputs, and declaring how the action is to be run:name: "Compose social media message" description: "Compose a message for social media, using the title, the URL and the tags of a new post" inputs: title: description: "Title of the post" required: true url: description: "URL of the post" required: true tags: description: "Tags of the post" required: true outputs: message: description: "Composed message for social media" runs: using: "node20" main: "index.js"
- Install the
@actions/core
package for handling action inputs and outputs in code. Of course, I could install any other packages needed by my code:npm install @actions/core
Write the actual JavaScript code I wanted to run (along with some extra calls to get the inputs, set the output, and handle errors):
const core = require("@actions/core"); try { const title = core.getInput("title"); const url = core.getInput("url"); const tags = core.getInput("tags"); const hashTags = JSON.parse(tags) .map( (tag) => `#${tag .toLowerCase() .replace(/^\./, "dot") // replace leading dot with 'dot' .replace(/#/g, "sharp") // replace '#' with 'sharp' .replace(/\s/g, "")}` // remove whitespace ) .join(" "); const message = `${title}\n\n${url}\n\n${hashTags}`; core.setOutput("message", message); } catch (error) { core.setFailed(error.message); }
Commit all of these files (and of course the generated
package-lock.json
file) in my git repository.
From my workflow, I could run such an action the same way as any public action published in the marketplace. The only difference was how it is being referenced in uses
property: via its path instead of via its repository:
- name: Compose social media message
id: social-media-message
if: ${{ env.NEW_POST == 'true' }}
uses: ./.github/actions/compose-post
with:
title: ${{ steps.post-frontmatter.outputs.title }}
url: ${{ env.POST_URL }}
tags: ${{ steps.post-frontmatter.outputs.tags }}
However, there was still one step missing, therefore the code in my action failed to access the referenced npm package:
Error: Cannot find module
@actions/core
That's because GitHub Actions runner doesn't install the npm packages. The documentation suggests two approaches:
- either commit the
node_modules
folder in your repository, - or compile your code and the packages into distribution files and commit those.
I chose a third approach: since my package is private, and I already install Node.js in my workflow to build the blog, I simply install the packages for my action before running it (notice that working-directory
is set to the folder with my action):
- name: Install dependencies for compose-post action
if: ${{ env.NEW_POST == 'true' }}
run: npm ci
working-directory: ./.github/actions/compose-post
Once you know exactly what you need to do, it's really simple to run some custom code inside your GitHub Actions workflow, at least if you're familiar with JavaScript. Even more so, if the project you're creating the workflow for is also JavaScript-based.