1 Reference CI CD
picklemustard edited this page 2026-09-07 15:49:27 -04:00

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.sh in the badges job and force-pushed to an orphan badges branch, 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-server and gbb-gateway for linux/amd64 and linux/arm64, static, with the version linked in — and asserts that gbb-server -version prints it.
  • test is the same gate ci.yml runs, against the same commit.
  • release pushes the images, then scripts/release.sh does 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-server and gbb-gateway images tagged :vX.Y.Z and :latest, each checked with -version after the push so an image stamped dev aborts the run before anything claims the release exists;
  • gbb-base, gbb-linux and gbb-windows — the SCons toolchain — at the same two tags;
  • a chore(release): vX.Y.Z commit on main pinning deploy/k8s/kustomization.yaml and build.image_tag in deploy/k8s/configmap.yaml to 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-dind service container for the daemon, addressed as DOCKER_HOST=tcp://dind:2375;
  • the static Docker CLI binary, fetched into /usr/local/bin. Just the client — docker.io from 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.