CI and releases
Everything here runs on the project's own Forgejo instance
(git.picklemustard.dev/StudioTeal/GodotBinaryBuilder) through
forgejo-runner. There is no GitHub, and several decisions below exist
specifically because Forgejo Actions is not GitHub Actions.
Workflows
| File | Trigger | What it does |
|---|---|---|
.forgejo/workflows/ci.yml |
push (all branches but badges), pull_request |
dedupe guard, Go gate, frontend gate, README badges, :latest images |
.forgejo/workflows/bump-patch.yml |
manual | version → build → test → release, patch bump |
.forgejo/workflows/bump-minor.yml |
manual | same, minor bump |
.forgejo/workflows/bump-major.yml |
manual | same, major bump |
Why a dedupe guard
Pushing to a branch that has an open pull request fires both push and
pull_request: synchronize. Both would run the full suite against
substantially the same tree, on a self-hosted runner where that is real time.
Concurrency cannot collapse them. The two events carry different refs, so they land in different concurrency groups; forcing them into one group would only make whichever started second cancel the first, which is a race over which result survives rather than a fix.
So the guard job decides. It asks the Forgejo API whether the pushed branch
has an open PR, and if it does, the push run stands down — the
pull_request run is the better of the two anyway, because Forgejo checks out
the merge ref, so what it tests is the merge commit rather than the branch
head.
Every path that cannot reach a conclusion — no token, no jq, an unreachable
API, a non-numeric answer — runs the tests. An outage must not be able to turn
CI off silently.
The net behaviour:
| Event | Runs the suite? |
|---|---|
| Push to a branch with no open PR | yes, once |
| Opening a PR | yes, once (merge ref) |
| Push to a branch with an open PR | once — the pull_request run; the push run stands down |
| Merging a PR into main | yes, once, on the merge commit |
The badges branch being force-pushed |
no — excluded by branches-ignore |
| A release tag being pushed | no — branches-ignore filters tag refs too |
Badges
README.md carries three:
- CI — Forgejo's built-in workflow-status badge. Nothing generates it.
- tests and coverage — rendered by
scripts/mkbadge.shin thebadgesjob and force-pushed to an orphanbadgesbranch, which the README links by raw URL.
The numbers are computed once, in the go job, and reach the badge job as
job outputs — three short strings. That job is a renderer: it has no Go
toolchain, no jq, and no opinion about what coverage means.
Outputs rather than an artifact, deliberately. upload-artifact@v4 does not
work here at all: it detects a Forgejo instance as GitHub Enterprise Server and
refuses, with
@actions/artifact v2.0.0+, upload-artifact@v4+ and download-artifact@v4+
are not currently supported on GHES.
So the artifact actions are pinned to @v3 everywhere, and the badge path
avoids them entirely. ci.yml still uploads coverage.out and test.json for
reading afterwards, but that step is continue-on-error — losing a debugging
convenience must not fail a green test run. The release workflows cannot hedge
that way: passing built binaries from build to release is what their
artifact upload is for, so it has to work.
Coverage is measured with -coverpkg=./cmd/...,./internal/.... Without it each
package measures only the code it defines, and the reported total moves when
packages are added rather than when coverage changes. make test-cover runs
the identical invocation, so a local number and the badge agree.
The badges branch is an orphan, rebuilt from scratch and force-pushed each
time. It is one commit deep forever — generated content with no history worth
keeping should not be paid for by everyone who clones the repo.
Releases
Three manually-dispatched workflows, one per bump size. Each is four jobs
chained with needs:, in the order the work has to happen:
version -> build -> test -> release
- version refuses to run off main, then computes the next tag with
scripts/next-version.sh. Nothing is published; this job is cheap to fail. - build compiles
gbb,gbb-serverandgbb-gatewayfor linux/amd64 and linux/arm64, static, with the version linked in — and asserts thatgbb-server -versionprints it. - test is the same gate
ci.ymlruns, against the same commit. - release pushes the images, then
scripts/release.shdoes the tag, the Forgejo Release with attachments, the generic package registry upload, and the manifest tag bump.
They are separate jobs rather than separate workflow files because
chaining files requires the workflow_run trigger, whose Forgejo support is
version-dependent. A needs edge is not. The same reasoning keeps the image
job inside ci.yml, and is why the release test job repeats ci.yml's steps
instead of calling a reusable workflow.
They are separate files rather than one workflow with a dropdown input because a dropdown is one entry in the Actions list, and three distinct actions was the requirement.
What a release produces
For vX.Y.Z:
- an annotated git tag;
- a Forgejo Release with six binaries and a
SHA256SUMS; - the same files in the generic package registry under
generic/gbb/X.Y.Z/; gbb-serverandgbb-gatewayimages tagged:vX.Y.Zand:latest, each checked with-versionafter the push so an image stampeddevaborts the run before anything claims the release exists;gbb-base,gbb-linuxandgbb-windows— the SCons toolchain — at the same two tags;- a
chore(release): vX.Y.Zcommit on main pinningdeploy/k8s/kustomization.yamlandbuild.image_tagindeploy/k8s/configmap.yamlto that tag.
It does not run kubectl. No kubeconfig reaches CI; rolling the cluster
stays a decision someone makes, and the commit above is the record of what is
supposed to be running.
Images are amd64 only, matching ci.yml. The arm64 binaries are for running
gbb outside a cluster; multi-arch images would need buildx and a manifest
list.
The toolchain images
These are the images builds actually run in, and until they were published here
nothing produced them but a developer's make images and a docker save into
the dind sidecar (deploy/local/load-toolchain-images.sh). They existed in no
registry, at no version, so a cluster could not pull them.
scripts/publish-toolchain-images.sh is what the release runs. It holds the
logic rather than the workflow because the three bump files are byte-identical
apart from name: and BUMP:, and a change made in YAML is a change made three
times.
It does not rebuild when it does not have to. If build/docker/ is unchanged
since the previous release tag, the previous release's images are pulled and
re-tagged under the new version: several gigabytes of apt that would produce the
same content is not worth twenty minutes of a release. Any failure in that path
falls through to a full build, so a missing or half-pushed previous release
costs time and nothing else.
macOS is not published. macos.Dockerfile needs an Apple SDK that cannot
live in this repository, so CI has none and build-images.sh skips it with a
message. A release therefore ships base, linux and windows. Publishing
gbb-macos means running the same script by hand from a machine that holds the
SDK; see docs/macos-sdk.md.
The cluster then pulls them, which needs two things beyond the push:
build.image_prefix naming the registry and build.image_tag naming the
version (both in deploy/k8s/configmap.yaml, the second rewritten by
scripts/release.sh), and a build.registry credential. That last one is not
the imagePullSecret — see the note in the ConfigMap and in
docs/wiki/Configuration.md.
Docker in CI
The runner image has no Docker CLI and no daemon — the image job originally
failed with docker: command not found. Both jobs that build images (ci.yml's
image, and each bump workflow's release) therefore bring their own:
- a
docker:28-dindservice container for the daemon, addressed asDOCKER_HOST=tcp://dind:2375; - the static Docker CLI binary, fetched into
/usr/local/bin. Just the client —docker.iofrom apt would drag in a second engine and containerd for a daemon that is already running in the service.
Both jobs wait on /dev/tcp/dind/2375 before the first docker command, for the
same reason the go job waits on Postgres: forgejo-runner's support for a
service's own health check is not dependable.
This requires runner configuration outside this repository. dind needs
--privileged, so the runner must be allowed to start privileged service
containers. If it is not, these jobs fail when the service starts rather than
at docker: command not found.
The registry hostname is the public git.picklemustard.dev, while git
operations use the in-cluster address the runner is configured with. The dind
container has to be able to resolve and reach that public name to push; if it
cannot, the registry needs its in-cluster address here instead.
Branch protection
main is locked against direct pushes. That is instance state, not repository
content — no committed file turns it on — so scripts/protect-main.sh
reconciles it through the API:
FORGEJO_TOKEN=<repo admin token> scripts/protect-main.sh
One account is whitelisted: the release workflow commits the manifest tag bump to main. That is a deliberate hole, and it is a named machine account rather than a relaxed rule so that revoking its token closes it without touching the protection.
If you rename a job in ci.yml, update status_check_contexts in that script
to match. A rule waiting on a check that no longer reports blocks every merge.
Secrets
| Secret | Used by | Needs |
|---|---|---|
FORGEJO_API_TOKEN |
guard, badges, image, release |
read:issue (open-PR check), write:repository (badges branch, tags, the main commit, releases), write:package (container registry) |
One secret, not three. REGISTRY_USERNAME and REGISTRY_TOKEN are gone: they
were never created, so the registry login ran with an empty -u and failed
with username is empty. The registry is on the same instance the API token
already reaches, and one credential is one thing to rotate.
Scopes are fixed when a token is minted and cannot be added afterwards. A token
missing write:package fails at docker login, not at the push.
FORGEJO_API_TOKEN should belong to the same machine account the branch
protection whitelists -- and scripts/protect-main.sh will read that name out
of the token for you if you pass it as CI_TOKEN, rather than trusting its
forgejo-actions default to still be right.
The push username
Nothing hardcodes it. Both authenticated push sites (the badges job and
scripts/release.sh) and both registry logins run scripts/forgejo-actor.sh,
which asks /api/v1/user who the token belongs to.
This is not decoration. Forgejo refuses a basic-auth push whose username is not
the token's owner with a bare remote: Forbidden and HTTP 403 -- which reads
like a repository permissions problem and is not one. Asking means the machine
account can be renamed, or the token reissued to another account, without
touching a workflow, and a bad token reports itself with the API's own message
instead of surfacing later as a 403 from git.
What CI still does not cover
GBB_TEST_DSN is the only integration gate CI sets, so the Docker, S3, Redis
and Kubernetes suites all skip. See CONTRIBUTING.md for the full list of
gates and docs/tickets/m3-14-ci-docker-gate.md for why the Docker one is not
worth a dind service here.
Guide
- Home
- Getting Started
- Building Godot
- Configuration
- Modules and Administration
- API
- Architecture
- Deployment and Operations
- Security
- Troubleshooting
- Development
Reference
- API
- Architecture
- Modules
- Module registry
- Admin
- Artifacts
- Deployment
- Kubernetes execution
- Durable execution store
- Immutable build configuration
- Download gateway
- Public logs
- Anonymous verification
- Abuse enforcement
- Public service remediation
- Authelia administration
- CI/CD
- macOS SDK