Switching from Woodpecker to Gitea Actions

I’ve been self-hosting a Gitea instance (previously Gogs) since roughly 2017. Since Gitea has historically been a very minimal Git hosting solution (note: nowadays it is very featureful!), it did not come with a builtin CI/CD solution (for testing, building, packaging, etc.). Thus I was also hosting a Drone instance to go along with it, which I’ve been using for my self-hosted, continuous blog deployment (among other projects).

Drone was the first CI/CD solution that can be described as “container native”: by default, all jobs are executed within a container, and container images are the basic building block for CI pipelines. I was really impressed by this approach and since then it has been ingrained into my brain as a best practice.

Unfortunately, at some point the Drone source code was relicensed from Apache 2 to a proprietary license. This lead some community members to fork the last open source version into a new project called Woodpecker (read more about the details here)

Recently, Gitea introduced built-in support for “Actions”, their solution for a CI/CD system that is very closely modeled after GitHub Actions (like the rest of Gitea). This brings the major benefit that the large number of guides, tutorials and examples that are out there for GitHub Actions (which has seen rapid adoption since its introduction in 2018) can be used for Gitea Actions - this applies especially to the thousands of “Actions” (preconfigured containers that can be used as part of CI workflow) out there.

In addition, an operational benefit is that now I need to maintain and update only one service (Gitea) instead of two (Gitea + Woodpecker).

This post will go over how to enable Gitea Actions, how to set up an Actions Runner (with Podman, Quadlet and systemd) and finally how I converted the CI pipeline for my blog to the new format.

#  Enable Gitea Actions

Gitea Actions was initially introduced in 1.19 and has received rapid improvements and bug fixes in the following releases. Therefore it is generally recommended to install the latest stable release, but at least 1.20 to get a smooth experience.

As of 1.20, the Gitea Actions feature is not enabled out-of-the-box (see Gitea Actions FAQ). The following configuration parameter needs to be set in the app.ini file:

1
2
[actions]
ENABLED = true

If you are using the Gitea Helm chart (like me), you can enable it by adding the following to your values.yaml:

1
2
3
4
gitea:
  config:
    actions:
      ENABLED: true

Roll out the helm upgrade and we’re ready to go.

#  Setup Actions Runner

The runner is the component that takes care of executing the commands that make up the CI/CD pipeline, whereas the main Gitea server is just responsible for orchestrating the various pipelines and jobs (triggering on events, assigning jobs to runners, providing the UI etc.). Other CI/CD solutions call this component agent or worker.

The official runner supported by Gitea is called act_runner. Several deployment examples for various different platforms are available. For security and performance reasons, I’m hosting the runner inside a separate and minimal virtual machine.

I decided to take this opportunity play around with the Quadlet: a translation layer that natively integrates Podman with systemd. The gist of it is that container description files (which look very similar to systemd unit files) can be dropped into /etc/containers/systemd/* (see this description of all available options), which are then automatically translated into systemd units containing the Podman commands for necessary starting and stopping the described container as a service unit.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# /etc/containers/systemd/act-runner.service
[Unit]
Description=Gitea Actions runner
After=local-fs.target network-online.target

[Container]
Image=docker.io/gitea/act_runner:0.2.6
HostName=%H
Mount=type=bind,source=/var/run/podman/podman.sock,destination=/var/run/docker.sock
Volume=act-runner-data:/data
Environment=GITEA_INSTANCE_URL=https://<gitea-hostname>:443
Environment=GITEA_RUNNER_REGISTRATION_TOKEN=<token>

[Install]
WantedBy=default.target

Replace <gitea-hostname> with the hostname of your Gitea instance (in my case git.cubieserver.de).

When the runner launches for the first time, it uses a one-time token to register itself to the Gitea instance. After successful startup, the registration information is stored in /data/.runner, therefore this directory must be persisted: for this purpose the unit manifest mounts a Podman Volume to that directory (line 10).

To generate the a registration token for a runner available to all repositories on the Gitea instance, log in as an administrator on Gitea, switch to the Site Administration page (1), open the Runners section (2) and click on the Create new Runner button (3) - copy the token and replace it in the systemd file.

You can find more details about the act_runner in the Gitea documentation.

Sidenote: from an administrator point of view I find the design quite unfortunate, because it means that the runners are a stateful (they store some state locally which must be persisted) and since the registration token can only be used once, there is no way to use it in a reproducible infrastructure-as-code environment.

The unit also makes the Podman socket available inside the container (line 9) so the act_runner (inside the container) can spawn new containers on the host (aka. “docker-in-docker”).

Note that we pass the hostname of the machine onto the container (line 8) so we can easily identify the runner from Gitea web UI - otherwise, the container will get a random hostname like 40ee51a8dd22. Systemd will replace the %H by the hostname when the service starts, see systemd unit specifiers.

To test if the unit file works as expected, the Quadlet’s dry-run option can be used:

1
$ /usr/libexec/podman/quadlet -dryrun

Now we’re ready to launch the systemd service.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
$ systemctl daemon-reload

$ systemctl enable --now act-runner.service

$ podman contaner ls
CONTAINER ID  IMAGE                              CREATED         STATUS         NAMES
295be9e45edd  docker.io/gitea/act_runner:0.2.6   2 seconds ago   Up 2 seconds   my-runner

$ podman volume ls
DRIVER      VOLUME NAME
local       act-runner-data

Note that the logs can be viewed with podman logs my-runner or journalctl -u my-runner.

#  Enable Gitea Actions for the repository

While we have enabled the Gitea Actions feature in the first section, the setting still needs to be enabled for each repository that wants to use it. Go to the repository settings (1) and check the option Enable Repository Actions (2) - don’t forget to save the new settings (3).

Afterwards, a new tab called Actions should appear in the repository:

#  Convert pipeline definition

I won’t dive into too much detail here, instead I’ll just show the before and after. Refer to GitHub Actions Quickstart for a full-blown introduction to the pipeline manifest syntax and available options. The most significant difference between the two pipelines is that previously (with Woodpecker) I used an extra build step (and container image) for uploading the generated artifacts (HTML, CSS etc.). Now (with Gitea Actions) I’m using Hugo’s built-in deploy feature that does exactly the same.

Woodpecker CI pipeline manifest (.woodpecker.yml):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
pipeline:
  build:
    image: docker.io/klakegg/hugo:0.75.1-ext-alpine
    commands:
      - 'hugo version'
      - 'hugo --log --minify'

  publish:
    image: plugins/s3-sync
    settings:
      bucket: my-bucket-name
      source: 'public/'
      target: '/'
      access_key:
        from_secret: s3_access_key
      secret_key:
        from_secret: s3_secret_key
      region: eu-central-1
      endpoint: 'https://s3.example.com'
      path_style: true
      delete: true
      acl: public-read
    when:
      branch:
        - master

Gitea Actions pipeline manifest (.gitea/workflows/blog.yaml):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
name: Build Website
on: [push]

jobs:
  build-website:
    runs-on: [linux,x86]
    container:
      image: docker.io/klakegg/hugo:0.75.1-ext-alpine
    steps:
      - uses: actions/checkout@v3
      - if: ${{ gitea.ref_name == 'master' }}
        run: echo -n "blog.cubieserver.de" > target.txt
      - if: ${{ gitea.ref_name != 'master' }}
        run: echo -n "test-blog.cubieserver.de" > target.txt
      - run: echo "Building and publishing '$(cat target.txt)'"
      - run: hugo version
      - run: hugo --log --minify --baseURL "https://$(cat target.txt)/"
      - run: hugo deploy --target $(cat target.txt)
        env:
          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          AWS_REGION: eu-central-1 # can be any region for Minio

Unlike other CI solutions (e.g. Gitlab CI), secrets are not directly exposed as environment variables, instead they can be accessed through the secrets context (lines 19-22). In my opinion this is a good design because it avoids accidentally exposing credentials (for example when running env) and prevents unintended conflicts between variables set from secrets and from the environment.

Secrets can be added through the repository settings (1) in the tab Secrets (2).

Happy deploying!