loading . . . 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. Most other steps are 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 CI 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 CI 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. We will go with Woodpecker CI as it is readily available on Codeberg and does not require self-hosted runners.
Woodpecker CI 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 CI 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 pipeline files in `.woodpecker/` will trigger a build.
# Woodpecker CI 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 CI, 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 now take a look at workflow examples for Python projects using pixi. You should be able to adapt them to your language and tooling of choice.
## 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/[email protected]
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/[email protected]
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/[email protected]
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:[email protected]/${CI_REPO_OWNER}/${CI_REPO_NAME}.git" \
_pages; then
:
else
git clone --depth 1 \
"https://x-token:[email protected]/${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 "[email protected]"
- 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 CI.
## 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/[email protected]
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:[email protected]/${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 "[email protected]"
- git config --global user.name "CI"
- git remote set-url origin "https://x-token:[email protected]/${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 CI.
# 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:[email protected]/...`, 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. Name Website Comment
By clicking the button below, you agree to the publication of your comment on this page.
Post by e-mail
Opens your e-mail client. https://scaron.info/blog/migrating-continuous-integration-from-github-to-codeberg.html