Building a Forgejo Actions Runner on Podman for AsciiDoc Course Releases

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:

resources/Containerfile
# 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.

Actions > Runners page showing the "Create new runner" button and registration token
./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:

Runners list showing the asciidoctor runner online with its labels
Note

--labels is what lets .forgejo/workflows/release.yml say runs-on: asciidoctor and get exactly the build-env image, with zero image name baked into the workflow file itself. Add more labels here (more images, or host for running directly on the runner machine) as needed — no workflow changes required.

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:

forgejo-runner.service
[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:

Repository Settings with the Actions checkbox enabled

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:

Organization Actions secrets page showing RUNNER_TOKEN
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:

A successful release workflow run in the Forgejo 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

actions/checkout failed with "Repository path …​ is not under …​"

actions/checkout@v4 refuses to check out to any path: outside the primary checkout’s root — path: ../resources can never work. Replaced with a plain git clone --depth 1 into ../resources, which has no such restriction.

Cloning training_adoc/resources returned 403

The built-in GITHUB_TOKEN is scoped to the current repository only, not the whole organization. Added a dedicated repo-read secret instead of reusing the built-in token.

Clone still failed after adding the secret

The org-level secret had actually been created as RUNNER_TOKEN, while the workflow read secrets.RESOURCES_TOKEN — a naming mismatch, not a permissions problem. Pointed the workflow at the real secret name.

Manually-triggered workflow failed with Invalid input type: ""

Forgejo requires an explicit type: on every workflow_dispatch input; GitHub Actions defaults a missing type to string, Forgejo does not. Added type: string explicitly.

Asset upload returned 404

/releases/tags/{tag}/assets is not a valid Forgejo/Gitea API route — uploads go to /releases/{id}/assets. Captured the numeric release id from the POST /releases response with jq -r .id and used that instead of the tag.

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

resources/Containerfile

Pins the whole build toolchain (Asciidoctor, decktape, Chromium, monolith, fonts) into one build-env image, built once per runner machine.

podman.socket (systemd)

Provides /run/podman/podman.sock on demand, replacing a manually backgrounded podman system service.

forgejo-runner.service (systemd)

Runs forgejo-runner daemon as a supervised service with Restart=on-failure.

runner-config.yml + --labels

Maps the asciidoctor label used in workflows to the local build-env image, so no image name lives in any workflow file.

Org secret RUNNER_TOKEN

Read access to the shared resources repository — the built-in token can’t cross repository boundaries.

.forgejo/workflows/release.yml

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.

Categories: git 
Tags: sysadm docker podman 

See also