Using Forgejo Actions without nodejs
2026-01-15 - Removing dependencies always feels good
Tags: Bash Forgejo
Introduction
I have been using Forgejo Actions to run CI tasks for about a year. I have been mostly content with them because they are well integrated with Forgejo itself, but I always thoroughly disliked relying on a stack of nodejs Actions that do way too much and that need to be updated way too often.
With all the supply chain attacks that make the headlines on hacker news, I started experimenting with simple handwritten shell actions that do only the minimal subset of what I need.
Checkout
The most commonly used Action must be actions/checkout which is the first step
of almost every CI job. I suspect most Action users must have never looked at
the source code of this action, but I encourage you to do so. Excluding tests,
there are about 3k lines of typescript code, and it in turn has dependencies on
other actions and libraries.
After years of GitHub Actions and now Forgejo Actions, I can count on one hand the number of times when I needed more than the simplest shallow clone. Therefore I replaced this action with:
- name: 'checkout'
run: |
set -eu
git init --quiet --initial-branch main
git remote add origin "https://git.adyxax.org/${GITHUB_REPOSITORY}.git"
git fetch --quiet --no-tags --depth=1 origin "$GITHUB_SHA"
git checkout --quiet "$GITHUB_SHA"
When I need to support authentication, submodules, git LFS, or to checkout only a few sparse files, this small boilerplate can easily be taylored.
Complete workflow example
Using the same principles of minimal shell-based steps all the way, here is what this website’s main workflow looks like now:
---
on:
push:
branches:
- 'main'
workflow_dispatch:
jobs:
all:
runs-on: 'self-hosted'
steps:
- name: 'checkout'
run: |
set -eu
git init --quiet --initial-branch main
git remote add origin "https://git.adyxax.org/${GITHUB_REPOSITORY}.git"
git fetch --quiet --no-tags --depth=1 origin "$GITHUB_SHA"
git checkout --quiet "$GITHUB_SHA"
- name: 'fmt'
run: './make.sh tidy --fail-if-dirty=true'
- name: 'build'
run: './make.sh build'
# we need the build step done before running check because the `search`
# server embeds the `index.html` file, which gets generated by hugo during
# the build step.
- name: 'check'
run: './make.sh check'
- name: 'deploy'
run: './make.sh deploy'
env:
SSH_PRIVATE_KEY: '${{ secrets.SSH_PRIVATE_KEY }}'
The make.sh script from this example allows for both local development and CI
Actions which is how I prefer to think about CI: as a thin wrapper around the
same commands you run locally.
I use a shell script because I am comfortable with shell scripting, but it could be a Perl or Raku script, a GNUmakefile or another tool as long as it can drive the full workflow locally using the same entrypoints as the CI.
Conclusion
I do not mind a little boilerplate, especially when I know I will rarely need to update it. And if I ever do need to update it, it is so simple that I fully understand and control it.
I did not mention a nice bonus: my CI is four times faster! The time spent building my blog went from about 24 seconds all the way down to about 6 seconds!
The true test on this journey will be to migrate my Terraform providers Actions workflows, more about this in another article.