I have always built and released these courses locally: run make zip on my
own laptop, copy the result somewhere, done. It works, but it is entirely
manual — easy to forget a step, and only possible from that one laptop. I
wanted a more standard way of doing this: push a tag, and have a real build
happen somewhere else, the same way every time.
My AsciiDoc training repositories build workbooks, decks and zip bundles
through make targets that need a real toolchain: Asciidoctor, decktape
Chromium for presentation PDFs, monolith for standalone HTML, ImageMagick for
the frontpage art. None of that belongs on a shared, generic CI box, so I
run my own Forgejo Actions runner, backed
by Podman, on a dedicated machine.
This post is the full path from nothing to a working release workflow that
tags a course, builds the zip, and uploads it as a Forgejo release asset — both the Forgejo side and the runner-machine side.
The build environment: a Containerfile, not a random VM
Every course repository’s Makefile includes a shared
resources/make/common.mk. That file assumes a specific toolchain is on
PATH. Rather than provisioning that by hand on the runner, it is captured
once as a Containerfile in the shared resources repository:
# Build environment for my AsciiDoc trainings.
#
# Bundles everything resources/make/common.mk needs so a Forgejo Actions
# runner can build a course exactly like a local `make rebuild` does:
# asciidoctor-pdf/-revealjs/-diagram, decktape + Chromium, monolith, and
# the ImageMagick/cowsay toolchain used by scripts/mkcow + scripts/mkfp.
#
# Build from the root of the `resources` repo:
# podman build -t build-env -f Containerfile .
#
FROM debian:bookworm-slim
ENV DEBIAN_FRONTEND=noninteractive \
LANG=en_DK.utf8 \
LC_ALL=en_DK.utf8 \
PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games"
RUN apt-get update && apt-get install -y --no-install-recommends \
locales \
ruby ruby-dev build-essential \
python3 \
git zip unzip curl ca-certificates jq \
cowsay imagemagick fontconfig \
chromium \
nodejs npm \
&& sed -i -e '/en_DK.UTF-8/s/^# //' -e '/en_US.UTF-8/s/^# //' -e '/nl_NL.UTF-8/s/^# //' /etc/locale.gen \
&& locale-gen \
&& sed -i 's/rights="none" pattern="@\*"\/>/rights="read" pattern="@*"\/>/' /etc/ImageMagick-6/policy.xml \
&& rm -rf /var/lib/apt/lists/*
# decktape renders the reveal.js decks to PDF (resources/scripts/prt).
# Pinned to the version known to work with this reveal.js.
RUN npm install -g decktape@3.16.1
# monolith inlines the standalone presentation HTML (resources/make/common.mk
# `standalone` target). Prebuilt binary - no Rust toolchain needed in the image.
ARG MONOLITH_VERSION=v2.10.1
RUN curl -fsSL -o /usr/local/bin/monolith \
"https://github.com/Y2Z/monolith/releases/download/${MONOLITH_VERSION}/monolith-gnu-linux-x86_64" \
&& chmod +x /usr/local/bin/monolith
# Asciidoctor toolchain, pinned to the versions verified locally.
RUN gem install --no-document \
asciidoctor:2.0.26 \
asciidoctor-pdf:2.3.24 \
asciidoctor-revealjs:5.2.0 \
asciidoctor-diagram:3.2.1 \
rouge:5.0.0
# Debian's `imagemagick` package is v6, which has no `magick` binary at all -
# that entry point is IM7-only. scripts/mkcow calls `magick` unconditionally
# (unlike scripts/mkfp, which already detects v6 vs v7 and falls back to
# `convert`), so give it a `magick` it can call: `-list ...` maps to the v6
# `identify -list ...`, everything else maps to `convert` (how IM7's `magick`
# behaves when the first argument isn't a recognized subcommand keyword).
RUN printf '%s\n' \
'#!/bin/sh' \
'if [ "$1" = "-list" ]; then shift; exec identify -list "$@"; fi' \
'exec convert "$@"' \
> /usr/local/bin/magick \
&& chmod +x /usr/local/bin/magick
# Fonts used both by the reveal.js/PDF themes and, by name, by ImageMagick
# in scripts/mkcow + scripts/mkfp (FreeSans, SauceCodePro-NFM-SemiBold).
COPY fonts/*.ttf /usr/share/fonts/truetype/atcomp/
RUN fc-cache -f \
&& magick -list font | grep -qx ' Font: FreeSans' \
&& magick -list font | grep -qx ' Font: SauceCodePro-NFM-SemiBold'
# decktape/puppeteer can't use Chromium's setuid sandbox inside a container.
RUN printf '%s\n' \
'#!/bin/sh' \
'exec /usr/bin/chromium --no-sandbox --disable-dev-shm-usage "$@"' \
> /usr/local/bin/chromium-nosandbox \
&& chmod +x /usr/local/bin/chromium-nosandbox
ENV CHROME_PATH=/usr/local/bin/chromium-nosandbox
Build it once on the runner machine:
cd resources
podman build -t build-env -f Containerfile .
Anything a course build needs that isn’t in this list gets added here, once, for every course — not patched onto a runner by hand.
The runner machine: Podman instead of Docker
The runner box uses Podman rather than Docker: no long-running root daemon
by default, and it speaks the same Docker API over a Unix socket, which is
all forgejo-runner needs.
Installing forgejo-runner
Download the binary release matching the Forgejo/Gitea Actions runner
protocol from the
Forgejo runner releases
page and drop it in ~runner/forgejo-runner, executable.
Registering the runner against git.example.com
Forgejo Actions runners register once, using a short-lived registration token from the Forgejo UI, and get a long-lived runner token in return.
./forgejo-runner register \
--instance https://git.example.com \
--token <registration-token-from-the-UI> \
--name asciidoctor-runner \
--labels asciidoctor:docker://build-env,ubuntu-latest:docker://node:20-bookworm
The important bit is the --labels mapping: asciidoctor:docker://build-env
means any workflow with runs-on: asciidoctor gets a container started from
the local build-env image I just built, on this runner. That mapping (plus
the instance URL and runner token) is written to runner-config.yml in the
runner’s home directory.
Once registered, the runner shows up online in the same Actions > Runners screen:
|
Note
|
|
Keeping the runner running: systemd, not a backgrounded shell
The obvious way to start things by hand is:
export XDG_RUNTIME_DIR=/var/run/podman
sudo podman system service -t 0 &
export DOCKER_HOST=unix://${XDG_RUNTIME_DIR}/podman.sock
./forgejo-runner daemon --config ~runner/runner-config.yml
That works interactively, but doesn’t survive a reboot or a crash, and the
backgrounded podman system service has no supervisor. Two systemd units
replace it:
Podman already ships a system-wide podman.socket unit that provides
/run/podman/podman.sock on demand — no custom socket-launch code needed,
just enable it:
sudo systemctl enable --now podman.socket
Then a small unit for the runner daemon itself:
[Unit]
Description=Forgejo Actions Runner
After=network-online.target podman.socket
Wants=network-online.target
Requires=podman.socket
[Service]
Type=simple
Environment=DOCKER_HOST=unix:///run/podman/podman.sock
WorkingDirectory=/home/runner
ExecStart=/home/runner/forgejo-runner daemon --config /home/runner/runner-config.yml
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
sudo cp forgejo-runner.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now forgejo-runner.service
Requires=podman.socket guarantees the socket exists before the runner
daemon starts, and Restart=on-failure gets automatic recovery that the
backgrounded & command never had.
The Forgejo side: enabling Actions and secrets
Turning Actions on
Forgejo Actions is not necessarily on by default on a self-hosted instance — it needs [actions] ENABLED = true in app.ini, and then Actions has to be
enabled per-repository (or per-organization) in Settings:
Secrets: don’t rely on the built-in token
Each course repository clones the shared resources repository as part of
its build. The built-in GITHUB_TOKEN that Forgejo injects automatically is
scoped to the current repository only, so cloning a sibling repository with
it returns a 403. The fix is a dedicated organization-level secret with real
read access to training_adoc/resources, added under
Organization Settings > Actions > Secrets:
rm -rf ../resources
git clone --depth 1 \
"https://x-access-token:${RUNNER_TOKEN}@git.example.com/training_adoc/resources.git" \
../resources
That one secret, named RUNNER_TOKEN, is shared by every course repository’s
workflows.
The workflow: .forgejo/workflows/release.yml
With the runner online and the secret in place, the workflow itself is
straightforward: verify the pushed tag is on main and matches
settings.adoc’s `:revnumber:, clone the shared resources, build the zip,
create a Forgejo release and upload the zip as its asset.
name: release
on:
push:
tags:
- 'v*.*.*'
jobs:
release:
runs-on: asciidoctor
steps:
- name: Check out course
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Verify tag is on main
run: |
git fetch origin main:refs/remotes/origin/main
if ! git merge-base --is-ancestor "${{ github.sha }}" origin/main; then
echo "Tag ${{ github.ref_name }} is not on main, aborting." >&2
exit 1
fi
- name: Verify tag matches settings.adoc version
run: |
VERSION=$(awk '$1 == ":revnumber:" { print $2 }' settings.adoc)
if [ "${{ github.ref_name }}" != "$VERSION" ]; then
echo "Pushed tag ${{ github.ref_name }} does not match settings.adoc revnumber $VERSION" >&2
exit 1
fi
- name: Check out shared resources
env:
RESOURCES_TOKEN: ${{ secrets.RUNNER_TOKEN }}
run: |
rm -rf ../resources
git clone --depth 1 "https://x-access-token:${RESOURCES_TOKEN}@git.example.com/training_adoc/resources.git" ../resources
- name: Build zip
run: make zip
- name: Create release and upload zip
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
API="https://git.example.com/api/v1/repos/${{ github.repository }}"
TAG="${{ github.ref_name }}"
RELEASE_ID=$(curl -sS -f -X POST -H "Authorization: token $GITHUB_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"tag_name\":\"$TAG\",\"name\":\"$TAG\",\"target_commitish\":\"${{ github.sha }}\"}" \
"$API/releases" | jq -r .id)
NAME=$(awk '$1 == ":docname:" { print $2 }' settings.adoc)
VERSION=$(awk '$1 == ":revnumber:" { print $2 }' settings.adoc)
for zip in training_${NAME}_${VERSION}*.zip; do
[ -e "$zip" ] || continue
echo "Uploading $zip"
curl -sS -f -X POST -H "Authorization: token $GITHUB_TOKEN" \
-H "Content-Type: application/zip" \
--data-binary "@$zip" \
"$API/releases/$RELEASE_ID/assets?name=$zip"
done
A push of a matching tag now shows a green run in the Actions tab:
What actually went wrong getting here
None of the above worked first try. The real path to a working workflow ran through a handful of very Forgejo/Gitea-specific gotchas:
| Symptom | Cause and fix |
|---|---|
|
|
Cloning |
The built-in |
Clone still failed after adding the secret |
The org-level secret had actually been created as |
Manually-triggered workflow failed with
|
Forgejo requires an explicit |
Asset upload returned |
|
Every one of these surfaced as a red run in the Actions tab with a clear enough error to point at the fix — but knowing the fix up front would have saved five separate pushes.
Summary
| Piece | Role |
|---|---|
|
Pins the whole build toolchain (Asciidoctor, decktape, Chromium, monolith, fonts) into one |
|
Provides |
|
Runs |
|
Maps the |
Org secret |
Read access to the shared |
|
Verifies the tag, builds the zip, creates the release and uploads the asset by release id. |
Six pieces, each solving one specific failure mode above — and now every
course repository on git.example.com gets a v1.2.3 tag turned into a
downloadable release zip automatically.