This article is about Forgejo, a code forge: Just like GitHub or GitLab it's a place to (collaboratively) develop software. I've already explained at length why we benefit greatly from Open-Source. Motivated by that concept, Forgejo's amazing community and perhaps because it's incredibly easy to set up, you select it for your private code forge. Though, with your Forgejo instance up and running you might miss a few features. I, for example, needed to receive email notifications and webhooks when a CI Workflow failed. Forgejo didn't offer that feature so I started contributing to Forgejo. You might find yourself in those shoes, too. In this article I want to help you and give my experience contributing to Forgejo. The parts that took the most effort where
Though I argue my development setup specifics are highly valuable to anyone getting into this, I'll move that part to the end for reasons of presentation.
Also, be aware that I'm describing all of this as of commit b2c4fc9f94
from 21st July 2025.
Some of this information may be outdated by the time you read it.
Forgejo uses Go for its backend.
I've always found Go's module system illusive, especially its' use of domains as module paths.
Also, Go does a lot of things implicitly, for example:
How do you tell Go that some function is a unit test?
You place it in a file with the _test.go
suffix.
Or another one:
How do you declare a symbol to be exported or not-exported (analogous to public or private in other languages)?
There's no keyword for that.
Instead, symbols with a leading capital letter are implicitly exported, otherwise not.
Those things are hard to figure out if you haven't inhaled the Go docs and just read through a project for the first time.
Therefore, I want to quickly explain how Go handles dependency.
There also is an official generic explanation if you prefer that.
Okay, so, you find Forgejo's source code on codeberg.org/forgejo/forgejo. In this section we'll concern ourself with this subsection of Forgejo's files:
/
├ ── go.mod
├── main.go
├── cmd
│ └─ main.go
└── modules
└─ log
└─ init.go
Firstly, there are modules.
A Go library is a module, a Go executable too.
Typically one git repo houses a single Go module.
Forgejo is a binary Go module with the module path forgejo.org
.
We can figure that out by looking at its go.mod
file.
// /go.modmodule forgejo.org// --snip--require ( code.forgejo.org/f3/gof3/v3 v3.11.1// --snip--
We notice that Forgejo uses the code.forgejo.org/f3/gof3/v3
Go module; that's a dependency.
As we've seen, Go code is grouped in modules but there's another level of granularity: packages.
Go uses packages to isolate code into neat, contained, well, packages.
Every Go file declares what package they are in.
Go code can use exported and not-exported symbols inside its own package (i.e., functions, values, etc.).
To use symbols in other packages, however, they must be exported and you need to import that other package.
The package main
is special, that's where the entrypoint is.
Take a look at Forgejo's main.go
:
// /main.go// --snip--package mainimport ( // [standard library imports] "os" "runtime" // --snip--
// [import some other package from the Forgejo source code repo] "forgejo.org/cmd"// --snip--)// --snip--func main() { // --snip--
Now, this is what really confused me at first:
forgejo.org
is a domain you can visit with your browser but that's more or less just a coincidence.
Go only uses it to identify Forgejo's main package's path.
Therefore, forgejo.org/cmd
is not a web endpoint at all, even though it might look like one.
Instead, it refers to the package cmd
located in the /cmd
directory.
// /cmd/main.go// --snip--package cmd
import ( // --snip-- "forgejo.org/modules/log" // --snip-- "github.com/urfave/cli/v3"// --snip--
And the forgejo.org/cmd
package imports some more packages.
When you read this, be aware of the to-be-imported package's location in the directory structure.
The package lies in the /modules/log
directory of the forgejo.org
module, thus forgejo.org/modules/log
refers to it.
github.com/urfave/cli/v3
, however, refers to a dependency Go will download from GitHub during a build.
The includes look so similar but one is resolved locally and the other from the internet.
Notice that the Go files in the forgejo.org/modules/log
package declare their package without its full path:
// /modules/log/init.go// --snip--package log// --snip--
It only says log
but the full package path required to import the package is forgejo.org/modules/log
.
Remember how the directory Go code lies in influences any package's path.
This article is about implementing one specific feature, actions notifications. Therefore, I'll only talk about the parts of Forgjeo's architecture that matter for this feature. Firstly, we take a look at Forgjeo's layered architecture, which I've created a visualization of:
\routers
, \services
, \models
, \modules
and \templates
are the main directories where most of Forgejo's Go code lives.
As we can see, there are three layers.
Firstly, code in the bottom layers may only access packages inside their respective main directory.
So for example, code in \modules
may not access the Forgejo-specific database models defined in \models
.
(The Go compiler doesn't enforce this, code reviewers do.)
Packages in \modules
could theoretically be used as a library outside of Forgejo.
Secondly, the \services
module may access both \models
and \modules
but not the \routers
above.
Finally, the \routers
code may use everything below it.
There are other main directories that we don't care about.
Furthermore, I've only drawn example packages, files and structs inside the main directories.
They contain a lot more things; things we'll look at later.
Lastly, the api
package inside \routers
doesn't actually contain any Go code directly.
Instead it is the parent-package of packages like forgejo.org/services/api/actions/runner
.
Naturally, we want as little code as possible in the upper layers. Code in such layers is a lot harder to reason about. After all it has access to so much stuff with so many effects and side-effects. This will become important when I talk about the refactoring my feature required.
We've seen how Go packages may include other Go packages. There's one problem with that:
TODO: cyclic stuff -> solution observer pattern
TODO
~/forgejo λ vit lg | grep -P '\(#(7510|7491|7697|7509|7508|8066|8250|8227|8242)\)'
cf4d0e6c34 b2c4fc9f94 9e6f722f94 2529923dea d17aa98262 386e7f8208 95ad7d6201 05273fa8d2 a783a72d6b
(git log --pretty=format: --name-only cf4d0e6c34~..cf4d0e6c34 ; \
git log --pretty=format: --name-only b2c4fc9f94~..b2c4fc9f94 ; \
git log --pretty=format: --name-only 9e6f722f94~..9e6f722f94 ; \
git log --pretty=format: --name-only 2529923dea~..2529923dea ; \
git log --pretty=format: --name-only d17aa98262~..d17aa98262 ; \
git log --pretty=format: --name-only 386e7f8208~..386e7f8208 ; \
git log --pretty=format: --name-only 95ad7d6201~..95ad7d6201 ; \
git log --pretty=format: --name-only 05273fa8d2~..05273fa8d2 ; \
git log --pretty=format: --name-only a783a72d6b~..a783a72d6b) | sort -u | uniq
models/actions/main_test.go
models/actions/run.go
models/actions/run_job.go
models/actions/run_test.go
models/actions/schedule.go
models/actions/task.go
models/forgejo_migrations/migrate.go
models/forgejo_migrations/v34.go
models/user/user_system.go
models/webhook/webhook.go
models/webhook/webhook_test.go
modules/structs/action.go
modules/structs/hook.go
modules/structs/repo_actions.go
modules/webhook/structs.go
modules/webhook/type.go
options/locale/locale_en-US.ini
options/locale_next/locale_en-US.json
routers/api/actions/runner/runner.go
routers/api/v1/repo/action.go
routers/api/v1/repo/repo.go
routers/api/v1/swagger/repo.go
routers/web/repo/actions/view.go
routers/web/repo/issue.go
routers/web/repo/setting/setting.go
routers/web/repo/setting/webhook.go
services/actions/clear_tasks.go
services/actions/job_emitter.go
services/actions/notifier.go
services/actions/notifier_helper.go
services/actions/schedule_tasks.go
services/actions/schedule_tasks_test.go
services/actions/task.go
services/actions/workflows.go
services/convert/action.go
services/convert/convert.go
services/forms/repo_form.go
services/mailer/mail_actions.go
services/mailer/mail_actions_now_done_test.go
services/mailer/mail_admin_new_user_test.go
services/mailer/main_test.go
services/mailer/notify.go
services/notify/notifier.go
services/notify/notify.go
services/notify/null.go
services/repository/branch.go
services/repository/setting.go
services/webhook/dingtalk.go
services/webhook/discord.go
services/webhook/feishu.go
services/webhook/general.go
services/webhook/general_test.go
services/webhook/matrix.go
services/webhook/msteams.go
services/webhook/notifier.go
services/webhook/notifier_test.go
services/webhook/shared/payloader.go
services/webhook/slack.go
services/webhook/sourcehut/builds.go
services/webhook/telegram.go
services/webhook/webhook.go
services/webhook/wechatwork.go
templates/mail/actions/now_done.tmpl
templates/swagger/v1_json.tmpl
templates/webhook/shared-settings.tmpl
tests/integration/actions_notifications_test.go
tests/integration/actions_runner_test.go
tests/integration/actions_run_now_done_notification_test.go
tests/integration/api_repo_actions_test.go
tests/integration/repo_webhook_test.go
They say testing is the hardest part. Maybe not the hardest but the most important and dullest. I'd like to argue system design is the most important but so what. Why you might ask? Besides ensuring regression, another perspective explain code That detailed perspective make the code author find a lot of her mistakes, too.
When something should work, test it does. When something should be forbidden, test it is.
TODO
To test the features I developed I don't just need the Forgjeo executable. No, I also need an action runner, a mail server and some place to send webhooks to.
Let's start with just getting a Forgejo test instance up and running.
USE_GOTESTSUM=yes
statements below.git clone https://codeberg.org/forgejo/forgejo ~/forgejo && cd ~/forgejo
.STRIP="0" EXTRA_GOFLAGS='-gcflags="all=-N -l"' TAGS="sqlite sqlite_unlock_notify" make build
.
It took me a while to realize that go build
enables optimization by default but keeps all debug symbols present.
We change that with the gsflags
by neither optimizing or inlining.
Furthermore, Forgejo's Makefile strips the debug symbols so we disable that with the STRIP
environment variable../gitea
.
Yes, the executable is still called that.~/forgejo/custom/conf/app.ini
.test_repo
repository and add the file .forgejo/workflows/main.yml
:
enable-email-notifications: trueon: workflow_dispatch:
jobs: test: runs-on: self-hosted steps: - name: Echo run: | echo Hello World! - name: Fail run: | false
git clone http://localhost:3000/chris_admin/test_repo.git ~forgejo_test_repo
.
You'll have to follow the prompts and configure your user.email
and user.name
.
I'm using password login, btw.To test workflow I need a runner.
~/forgejo_runner
../forgejo-runner-11.1.2-linux-amd64 register
, give the instance URL http://localhost:3000
, the runner token you get from the repo settings in the web interface, choose a name like test-runner
and select the label self-hosted:host
../forgejo-runner-11.1.2-linux-amd64 daemon
and click the workflow trigger button in the web interface.
You should see your workflow run now.Okay, that works fine but we also want to test sending emails. I use MailDev to create a development email server. It provides an SMTP server, which Forgejo connects to, and a webinterface for me, the developer.
docker run --network host -p 1080:1080 -p 1025:1025 maildev/maildev
.~/forgejo/custom/conf/app.ini
).
[mailer]ENABLED = truePROTOCOL = smtpSMTP_ADDR = localhostSMTP_PORT = 1025FROM = forgejo@localhost
# make sure this is true[service]ENABLE_NOTIFY_MAIL = true
Furthermore, we want to test webhooks.
webhook_tester.js
:
#!/usr/bin/env node
const http = require("http");
const hostname = "0.0.0.0";const port = 8001;
const server = http.createServer((req, res) => { console.log(`\n${req.method} ${req.url}`); console.log(req.headers);
req.on("data", function(chunk) { console.log("BODY: " + chunk); });
res.statusCode = 200; res.setHeader("Content-Type", "text/plain"); res.end("Hello World\n");});
server.listen(port, hostname, () => { console.log(`Server running at http://localhost:${port}/`);});
./webhook_tester.js
.~/forgejo/custom/conf/app.ini
).
For Forgejo to accept http://localhost:8001
as a webhook target you need to add this:
[webhook]ALLOWED_HOST_LIST = *SKIP_TLS_VERIFY = true
http://localhost:8001
.
Enable either All events or Custom events, selecting the Action Run events.Now we have everything to play with the features I implemented.
Forejeo has different types of tests. I was concerned with these types.
TAGS='sqlite sqlite_unlock_notify' USE_GOTESTSUM=yes make test
.TAGS='sqlite sqlite_unlock_notify' USE_GOTESTSUM=yes make test-sqlite
.Say you only want to run this unit test in ~/forgejo/models/actions/run_test.go
:
func TestGetRunBefore(t *testing.T) { // --snip--}
Then you can execute USE_GOTESTSUM=yes TAGS='sqlite sqlite_unlock_notify' GO_TEST_PACKAGES='forgejo.org/models/actions' make 'test#TestGetRunBefore'
.
But what if, instead, you are concerned with this integration test in ~/forgejo/tests/integration/actions_notifications_test.go
:
func TestActionNotifications(t *testing.T) { // --snip--}
Then you can run USE_GOTESTSUM=yes TAGS='sqlite sqlite_unlock_notify' make 'test-sqlite#TestActionNotifications'
.
Btw, there are other types of tests, namely frontend tests and the End-to-End tests in a special repo.
I didn't work with these yet so I direct you to the testing docs.
I like the terminal and am used to GDB. Therefore, I'm using the terminal debugger Delve. Let's set things up for that:
dlv exec ./gitea
.break forgejo.org/services/mailer.(*mailNotifier).ActionRunNowDone
, break forgejo.org/services/notify.ActionRunNowDone
and continue
.
Hit Ctrl+C
to enter a Delve command and type quit
to exit.Say you want to debug the above unit test.
Then you can use Delve with this command: dlv test --build-flags "-tags='sqlite,sqlite_unlock_notify' -run TestGetRunBefore" forgejo.org/models/actions
.
If, instead, you want to debug the above integration test, run make integrations.sqlite.test generate-ini-sqlite && GITEA_ROOT="$(pwd)" GITEA_CONF=tests/sqlite.ini dlv exec ./integrations.sqlite.test -- -test.run TestActionNotifications
.
Here you can break on some line number, too: break ./tests/integration/actions_notifications_test.go:22
To debug something else, take a look at Forgejo's Makefile and find what command make the things you want to debug run.
Just replace go test
with delve
and place all go test
arguments in the --build_flags
Delve argument.
You can break on line numbers, too: break ./models/actions/run_test.go:19
TODO