Migrating continuous integration from GitHub to Codeberg

Over the past few months, I've been experimenting with migrating projects from GitHub to Codeberg. Four projects in, I've found that the main non-trivial step is migrating continuous integration (CI) from GitHub Actions to Codeberg's instance of Woodpecker CI. Other steps are generally straightforward, as Codeberg is mostly at feature parity with GitHub and follows its main design principles. In this note, we thus focus on CI migration. First, we will summarize the steps to activate Woodpecker in your Codeberg repository, and second, we will see template CI pipelines for common use cases like linting, documentation and testing.

Activating Woodpecker CI

Codeberg has two continuous integration systems: Woodpecker CI and Forgejo Actions. Woodpecker is a standalone system with its own pipeline format, configured in .woodpecker/*.yml files. It spins up containers where each step runs in a Docker image. In what follows, we go for it as it is readily available on Codeberg and does not require self-hosted runners.

Woodpecker access on Codeberg is only provided for open-source projects, and as such it is not granted by default. The first step is to fill out a CI request issue: here is for instance the one I filed for lpsolvers, the first project I migrated. This approval is only needed once. Afterwards, CI access will be granted to all your future open-source projects.

Once access has been granted, you can activate continuous integration for your repository as follows:

  • Log into Woodpecker at ci.codeberg.org
  • Click on "Repositories" in the top menu
  • Click on + Add repository: this displays a list of your repositories
  • Click on Enable next to your repository

After activation, any push or pull request that includes workflow files in .woodpecker/ will trigger a CI pipeline.

Woodpecker workflows

A quick word on terminology, as workflows don't mean the same thing in GitHub Actions and Woodpecker CI. In GitHub Actions, workflows defined in .github/workflows/*.yml are the top-level unit of context that run in separate containers. Each workflow holds one or more jobs, each job defining a sequence of steps. In Woodpecker, the top-level unit is called a pipeline. Workflows are defined in .woodpecker/*.yml, but a workflow is directly a sequence of steps. The correspondence is then:

  • GitHub Actions: workflows define jobs that execute steps
  • Woodpecker CI: pipelines define workflows that execute steps

A Woodpecker workflow is therefore the equivalent of a GitHub Actions job. Beyond the naming, the semantics are familiar: workflows run in parallel in separate containers by default, dependencies are opt-in, and files are shared only within a workflow, so that passing artifacts across workflows needs external storage, just as it does across GitHub jobs.

Let us take a look at a few workflows, for instance, for Python projects using pixi.

Linting

On GitHub, a workflow named for instance .github/workflows/lint.yml would look like:

on: [push, pull_request]

jobs:
  lint:
    name: "Code style"
    runs-on: ubuntu-latest
    steps:
      - name: "Checkout sources"
        uses: actions/checkout@v4

      - name: "Setup pixi"
        uses: prefix-dev/setup-pixi@v0.8.8
        with:
          pixi-version: v0.44.0
          cache: true

      - name: "Run linting"
        run: |
          pixi run -e lint lint

On Codeberg, it becomes a similar .woodpecker/lint.yml:

when:
  - event: [push, pull_request]

steps:
  - name: lint
    image: ghcr.io/prefix-dev/pixi:latest
    commands:
      - pixi run -e lint lint

Note how the actions/checkout from GitHub Actions is gone: before running steps, Woodpecker automatically clones the repository (using the woodpeckerci/plugin-git image) by default into the workspace. The behavior can be overridden in a clone: section of the workflow if needed, for instance to shallow-clone with a specific depth.

Testing

On GitHub, a workflow named for instance .github/workflows/test.yml would look like:

on: [push, pull_request]

jobs:
  test:
    name: "Test with ${{ matrix.pyenv }}"
    runs-on: ubuntu-latest

    strategy:
      matrix:
        pyenv: [py310, py311, py312, py313]

    steps:
      - name: "Checkout sources"
        uses: actions/checkout@v4

      - name: "Setup pixi"
        uses: prefix-dev/setup-pixi@v0.8.8
        with:
          pixi-version: v0.44.0
          cache: true

      - name: "Test with pixi"
        run: |
          pixi run -e test-${{ matrix.pyenv }} test

On Codeberg, it becomes .woodpecker/test.yml:

when:
  - event: [push, pull_request]

matrix:
  include:
    - PIXI_ENV: test-py310
    - PIXI_ENV: test-py311
    - PIXI_ENV: test-py312
    - PIXI_ENV: test-py313

steps:
  - name: test
    image: ghcr.io/prefix-dev/pixi:latest
    commands:
      - pixi run -e ${PIXI_ENV} test

Documentation

On GitHub, a workflow named for instance .github/workflows/docs.yml would look like:

on: [push, pull_request]

jobs:
  docs:
    name: "GitHub Pages"
    runs-on: ubuntu-latest
    permissions:
      contents: write
    steps:
      - name: "Checkout Git repository"
        uses: actions/checkout@v4

      - name: "Setup pixi"
        uses: prefix-dev/setup-pixi@v0.8.8
        with:
          pixi-version: v0.44.0
          cache: true

      - name: "Build documentation"
        run: |
          pixi run -e docs docs-build

      - name: "Deploy to GitHub Pages"
        uses: peaceiris/actions-gh-pages@v3
        if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
        with:
          publish_branch: gh-pages
          github_token: ${{ secrets.GITHUB_TOKEN }}
          publish_dir: _build/
          force_orphan: true

On Codeberg, GitHub Pages is replaced by Codeberg Pages, which serves static files from a pages branch. The following pipeline builds Sphinx documentation, then deploys the output to that branch. The documentation will then be served at https://USERNAME.codeberg.page/REPONAME/.

On Codeberg, the pipeline becomes .woodpecker/docs.yml:

when:
  - event: [push, pull_request]

steps:
  - name: build
    image: ghcr.io/prefix-dev/pixi:latest
    commands:
      - pixi run -e docs docs-build

  - name: deploy
    image: alpine/git
    commands:
      - |
        if git clone --depth 1 --branch pages \
          "https://x-token:$$CBTOKEN@codeberg.org/${CI_REPO_OWNER}/${CI_REPO_NAME}.git" \
          _pages; then
          :
        else
          git clone --depth 1 \
            "https://x-token:$$CBTOKEN@codeberg.org/${CI_REPO_OWNER}/${CI_REPO_NAME}.git" \
            _pages
          git -C _pages checkout --orphan pages
        fi
      - git config --global --add safe.directory "$(pwd)/_pages"
      - git config --global user.email "ci@noreply.codeberg.org"
      - git config --global user.name "CI"
      - git -C _pages rm -rf --ignore-unmatch .
      - cp -r _build/. _pages/
      - git -C _pages add -A
      - git -C _pages diff --cached --quiet || git -C _pages commit -m "Deploy documentation [CI SKIP]"
      - git -C _pages push origin pages
    environment:
      CBTOKEN:
        from_secret: cbtoken
    when:
      - event: push
        branch: ${CI_REPO_DEFAULT_BRANCH}

Note the [CI SKIP] label in the automated commit: steps that push to branches must include it in their commit messages to prevent triggering new pipeline runs.

This template uses a cbtoken secret as it requires permissions to push to the pages branch. See below for a summary of the steps to follow to configure this secret in Woodpecker.

Coverage

GitHub projects commonly use Coveralls or Codecov for coverage reporting, but these services don't integrate with Codeberg. On Codeberg, we will go instead for a self-hosted solution where we generate a coverage/ subdirectory of the documentation website, populated by coverage html and pushed to the pages branch alongside the Sphinx docs. We also use genbadge to generate a badge.svg displayed in the readme and linking to the coverage report.

On GitHub with Coveralls, a workflow named for instance .github/workflows/coverage.yml would look like:

on: [push, pull_request]

jobs:
  coverage:
    name: "Coverage"
    runs-on: ubuntu-latest

    steps:
      - name: "Checkout sources"
        uses: actions/checkout@v4

      - name: "Setup pixi"
        uses: prefix-dev/setup-pixi@v0.8.8
        with:
          pixi-version: v0.44.0
          cache: true

      - name: "Install coveralls"
        run: |
          pip install coveralls

      - name: "Check code coverage"
        run: |
          pixi run -e coverage coverage

      - name: "Coveralls"
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          coveralls --service=github

In the Codeberg variant, we generate a coverage badge using genbadge locally from our coverage report:

mkdir -p coverage
coverage erase && coverage run -m unittest discover
coverage report --include='<project-name>/*'

# Generate the coverage badge
coverage xml --include='<project-name>/*' -o coverage/coverage.xml
genbadge coverage -i coverage/coverage.xml -o coverage/badge.svg

# Generate the HTML coverage report
coverage html --include='<project-name>/*' --directory=coverage/html/

We then push both the badge and HTML coverage reports to a sub-directory of the pages branch.

On Codeberg, the pipeline becomes .woodpecker/coverage.yml:

when:
  - event: [push, pull_request]

steps:
  - name: coverage
    image: ghcr.io/prefix-dev/pixi:latest
    commands:
      - pixi run -e coverage coverage

  - name: publish-coverage
    image: alpine/git
    commands:
      - git clone --depth 1 --branch pages
          "https://x-token:$$CBTOKEN@codeberg.org/${CI_REPO_OWNER}/${CI_REPO_NAME}.git"
          _pages
      - rm -rf _pages/coverage/
      - mkdir -p _pages/coverage
      - cp -r coverage/html/. _pages/coverage/
      - cp coverage/badge.svg _pages/coverage/badge.svg
      - rm -f _pages/coverage/.gitignore
      - cd _pages
      - git config --global --add safe.directory "$(pwd)"
      - git config --global user.email "ci@noreply.codeberg.org"
      - git config --global user.name "CI"
      - git remote set-url origin "https://x-token:$$CBTOKEN@codeberg.org/${CI_REPO_OWNER}/${CI_REPO_NAME}.git"
      - git add --all
      - git diff --cached --quiet || git commit -m "Update coverage report [CI SKIP]"
      - git push -u origin pages
    environment:
      CBTOKEN:
        from_secret: cbtoken
    when:
      - event: push
        branch: ${CI_REPO_DEFAULT_BRANCH}

depends_on:
  - docs

Note how we made the coverage workflow depend on the documentation one, so that the pages branch already exists when it runs. We also used again the [CI SKIP] label in the automated commit message so that pushing doesn't trigger an additional pipeline run.

This template uses a cbtoken secret as it requires permissions to push to the pages branch. See below for a summary of the steps to follow to configure this secret in Woodpecker.

Secrets

If your workflows perform some restricted operations, like pushing to branches for coverage or documentation, Woodpecker will need a Codeberg personal access token. You can set it up as follows:

  • On Codeberg:
    • Go to Settings
    • Create a personal access token with "Repository Read & Write" permissions.
    • Note down the generated string carefully. As far as I understand, you won't be able to display it again later on.
  • On Woodpecker CI:
    • Go to the repository's settings (cog icon)
    • Go to the Secrets tab
    • Add the copied token as a secret named cbtoken

In the template workflows above, secrets are exposed to steps via the environment block with from_secret:

environment:
  CBTOKEN:
    from_secret: cbtoken

The token can then be used in git clone or push URLs as https://x-token:$$CBTOKEN@codeberg.org/..., as in the documentation and coverage workflows above. The double $$ is important: Woodpecker pre-processes ${VAR} syntax and would resolve the token to an empty string before the shell ever sees it. Using $$CBTOKEN escapes Woodpecker's substitution so that the shell resolves the variable from the environment at runtime. This won't cause a leak, as secrets are automatically redacted from logs.

To go further

In this post, we used Woodpecker CI for continuous integration. After using it for 3-4 months, this system has given me the impression of being simple and to the point, and my overall experience has been entirely positive. If you are looking for more details not covered in this post, the main page to start from is Working with Codeberg's CI in the Codeberg documentation. You can also check out Woodpecker CI examples for other languages in the Codeberg-CI / examples repository.

Discussion

Feel free to post a comment by e-mail using the form below. Your e-mail address will not be disclosed.

📝 You can use Markdown with $\LaTeX$ formulas in your comment.

By clicking the button below, you agree to the publication of your comment on this page.

Opens your e-mail client.