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
Enablenext 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.