Skip to content

Source code

Every SVRBC repository is a project in the svrbc group on GitLab.com. The one subgroup is svrbc/forks, which holds a shared fork of each project. GitLab also runs CI (Builds and CI) and serves the static sites through GitLab Pages (Domains).

The group is on GitLab’s free tier, and CI jobs run on GitLab.com’s shared runners.

Every SVRBC repository’s primary branch is master. Not main, even though GitHub and newer git init default to it. One name everywhere means scripts, CI rules, branch protection and these docs never have to guess.

  • It is the group default, so a project created in GitLab starts on master. A repository created locally and pushed may not. Check with git symbolic-ref --short HEAD before the first push, or start it with git init -b master.
  • Nobody pushes to it, owners included. It changes only by merging a merge request, and nobody can force-push. Nobody merges by hand either: the merge bot does, once the owners have approved.

svrbc-audit reports any project whose default branch isn’t master, or whose master takes pushes.

Changes arrive as merge requests from forks

Section titled “Changes arrive as merge requests from forks”

Every change is a merge request from the project’s fork in svrbc/forks. The project itself keeps only master; branches live in the fork, which every member can push to. One fork per project, shared by everyone, with the project’s own name (svrbc/forks/doreancon.org).

Terminal window
git clone git@gitlab.com:svrbc/<project>.git # clone the project, not the fork
cd <project>
svrbc-fork # once: make sure the fork exists, add it as `fork`
git switch -c <topic> # …commit…
git fetch origin && git rebase origin/master # merges are fast-forward: start from current master
svrbc-fork mr # push to the fork, open the merge request

(svrbc-fork is in this handbook’s tools/.)

What happens next:

  1. It is built and tested once. On a trusted workstation, svrbc-fork mr does it before it pushes: it runs bazel test //..., uploads the results to the shared cache and records the commit as verified. Otherwise the merge bot starts the fork’s pipeline, which does the same (CI in svrbc/forks is trusted to write the cache). A push to a fork starts no pipeline by itself, so a commit already verified is never built again.
  2. Its owners approve it, and the merge bot merges it, fast-forward. The merged commit is exactly the commit that was built and tested, so its cache keys are the same.
  3. master starts no pipeline. There’s nothing for it to do: the merged commit is the one that was built and tested.
  4. Releases are merge requests too: a reviewed version bump, which the merge bot turns into the tag, and the tag’s pipeline deploys (Releases).
  5. The fork’s master catches up: the merge bot fast-forwards it, so a branch pushed later doesn’t carry a backlog of master’s commits up with it.

Why it’s set up this way:

  • The project stays clean: no branches, ever. Abandoned work is in the fork, where it bothers nobody, and the source branch is deleted when its merge request merges.
  • The forks belong to SVRBC. A personal fork leaves with its owner, and its CI runs on the owner’s account; a fork in svrbc/forks runs on SVRBC’s, with SVRBC’s cache.
  • Building once. A heavy change is built in its fork, and master and every developer’s machine reuse it. Only the fork pipeline writes; anyone outside SVRBC (a fork somewhere else) can’t.
Where Branch Push Merge Force-push
svrbc/<project> master Only the merge bot’s deploy key No one no
svrbc/<project> * (anything else) No one No one no
svrbc/forks/<project> master Maintainers (CI’s sync, as svrbc_bot) Maintainers no
svrbc/forks/<project> other branches Developers and up — yes, your own branch

Each project also merges fast-forward only, with squashing turned off, deletes the source branch on merge, and, where it has CI, requires the pipeline to pass. Both halves of “fast-forward only” matter: a merge commit or a squash would each put a new commit on master, not the one the fork built and tested. The forks are set the same way, so nothing lands on them differently either.

For a new project, an owner sets this up once:

Terminal window
P="projects/svrbc%2F<project>"
glab api -X DELETE "$P/protected_branches/master"
glab api -X POST "$P/protected_branches" -f name=master -F push_access_level=0 -F merge_access_level=40 -F allow_force_push=false
glab api -X POST "$P/protected_branches" -f name='*' -F push_access_level=0 -F merge_access_level=0
glab api -X PUT "$P" -f merge_method=ff -f squash_option=never -F remove_source_branch_after_merge=true -F only_allow_merge_if_pipeline_succeeds=true

Then, once its CODEOWNERS has merged, hand it to the merge bot: svrbc-merge-bot-enroll <project> adds the bot’s deploy key and webhooks and changes master to the first row above, and a merge request adds the project to tools/merge-bot/projects.txt. Enrolling also lets the bot’s key push the fork’s master, which the bot keeps in step after every merge.

An exception lets a tool push branches to the project itself, for a tool that can only work that way (a CMS that saves drafts as branches, say). It needs all three:

  1. An owner agrees, and adds a protected-branch rule for exactly that pattern, letting the role that needs it push.
  2. The project’s AGENTS.md explains it: the pattern, what creates those branches, and who cleans them up.
  3. It’s removed when the need goes.

A staging branch is the other kind: a long-lived branch that reviewed changes merge into, deployed to a staging site of its own rather than to production (svrbc.org’s next, at next.svrbc.org; master stays svrbc.org). It follows the same three rules, and is protected exactly as master is, push only by the merge bot’s deploy key and merge by no one, which is also what opts it in to the bot: the bot merges into master and into any branch so protected, by that branch’s own CODEOWNERS. Changes reach it the usual way, svrbc-fork mr --target <branch>; what is ready for production moves on to master by a merge request from the staging branch to master.

svrbc-audit fails a project with no * rule, and warns about an exception its AGENTS.md doesn’t mention and about any branch other than master that no exception covers.

Every project has a CODEOWNERS file saying who must approve its changes. It lives at .gitlab/CODEOWNERS, in GitLab’s format, and its first rule, *, names the project’s owners: whoever must approve any change. Narrower rules after it give parts of the project their own owners (the last matching rule wins):

# .gitlab/CODEOWNERS
* @cco3
/content/ @cco3 @a-content-editor

An owner is a person, by GitLab username. A change to CODEOWNERS is reviewed like any other, by the owners of *. doreancon.org’s is * @cco3: one owner for the whole project.

The rule is that a merge request needs approval from the owners of every path it changes. GitLab Free (svrbc’s plan) records approvals but never requires them, and can’t limit merging to named people; both are Premium. So nobody merges: a bot does, once the rule is met.

  • Approve with GitLab’s Approve button. That is all an owner does.
  • The bot merges when the latest commit’s pipeline has passed, or it has no pipeline and is verified, and every changed path has an approval from one of its owners given after the latest change. GitLab won’t let authors approve their own merge requests, so an author who owns what they changed adds the merge-bot::ready label instead; that counts as their approval. A rebase that leaves the changes alone keeps approvals; any other push resets them. Drafts never merge.
  • It rebases, as the last step. A merge request that isn’t on top of master is rebased by the bot (GitLab’s rebase, which keeps the approvals) once the owners have approved and nothing but its tests is missing, and only when GitLab sees no conflict; a conflict, or a rebase GitLab couldn’t do, is left to a person, and the bot’s comment says so. The rebased commit is new, so it’s tested again: one pipeline, mostly cache hits, which the bot starts. It doesn’t test the stale commit first, and it doesn’t rebase a merge request whose stale commit failed.
  • The rules come from master’s CODEOWNERS, never the merge request’s, so a merge request can’t change the rules it is judged by. The bot reads a strict subset of GitLab’s syntax (@username owners, sections such as [Docs][2] for two approvals and ^[Optional]) and refuses anything else with a message, rather than guessing. A path no line covers blocks the merge.
  • It runs a pipeline only when one is needed. When the latest commit isn’t verified and has no pipeline, the bot starts one in the fork. It never reruns a failed one: push a fix, or retry it in GitLab. A pipeline that did run always counts, verified or not, since a project’s pipeline may do more than the marker vouches for (the handbook’s plans its OpenTofu changes), so a project that still runs a pipeline on every push waits for it as before. Turning those off (Verified commits) is what lets a verified commit skip CI.
  • It labels where the tests stand: tests::passed (verified, or the pipeline passed), tests::testing (a pipeline is starting or running) or tests::failed. One at a time; it removes the others.
  • It keeps one comment on the merge request, edited in place, saying what it is waiting for.
  • It merges by pushing the merge request’s head commit to master with its deploy key (merges are fast-forward, so that push is the merge), and GitLab marks the merge request merged. master is protected so that only that key can push and nobody can merge.
  • Then it releases and syncs the fork: a version bump gets its v<version> tag, created through the API, and the bot starts the tag’s pipeline (Releases); the fork’s master is fast-forwarded with its key to match. Both are idempotent, and each 15-minute sweep finishes either if a run didn’t.

It acts within seconds of the approval or the pipeline: webhooks start it, and a run every 15 minutes catches anything they missed. The code is tools/merge-bot/, and it runs as an AWS Lambda function (merge-bot). The projects it looks after are listed in tools/merge-bot/projects.txt. svrbc-audit fails a project with no CODEOWNERS or no * rule, and warns about one the bot doesn’t look after yet.

Breaking glass. Only a group Owner can get around the bot, by changing master’s protection, the same limit Premium has. Do it only when the bot is down and a fix can’t wait; put the protection back with svrbc-merge-bot-enroll, and expect the audit to flag the project until then.

You can’t request access from the group page; that button is turned off. Ask an owner to add you. New members start as Developer, which is enough to contribute: push branches to the forks and open merge requests.

Role Can
Developer Push branches to svrbc/forks, open merge requests and issues, create projects
Maintainer Also change project settings. Merging is the merge bot’s, once a project’s owners approve
Owner Also manage members, subgroups and group settings, and grant exceptions

Nobody, owners included, pushes to a project’s master.

Clone over SSH once your key is on your GitLab profile:

Terminal window
git clone git@gitlab.com:svrbc/<project>.git

glab, GitLab’s CLI, is the easiest way to script against the API. The examples in this handbook use it. Log in once with glab auth login.

  • Create it in the svrbc group, not under your own account, so it never has to be transferred.
  • Public by default. Most SVRBC projects are public, and this handbook is written on that assumption. Make a project private only when its contents are private, such as member information or unpublished material. Even in a private project, credentials still go in CI/CD variables, never in the repository.
  • Name a project that publishes a site after its domain (baptistcatechism.org, psalter.svrbc.org). Name anything else in lowercase kebab case (two-by-two, street-tracts).
  • No svrbc- prefix inside the group: the group already says whose it is (svrbc/merge-bot, not svrbc/svrbc-merge-bot). Keep the prefix where names are global or shared: AWS resources (Naming), commands on PATH (svrbc-fork), and local clones in a flat ~/projects/ (~/projects/svrbc-merge-bot). A few older projects (svrbc-bulletin, svrbc-constitution, svrbc-nuxt-symbolics) predate this; renaming one breaks every clone and link, so it isn’t worth doing just for this.
  • Add an AGENTS.md that points back to this handbook. See Agents files.
  • Give it a GitLab avatar of its own (Settings → General → Project avatar), so lists, merge requests and notifications tell the projects apart at a glance. A project with a site uses the site’s logo; others build on SVRBC’s cross, the circuit-board one in svrbc.org’s static/logo-transparent.svg (this handbook’s is that cross in angle brackets). Put it on a solid background: a black logo on a transparent one disappears in GitLab’s dark theme.

A project nobody works on any more is archived, not left failing the handbook’s rules (Settings → General → Advanced → Archive project). An archived project is read-only: no pushes, merge requests or pipelines, though it stays visible and can be cloned, and a site it already deployed keeps serving. svrbc-audit and svrbc-merge-bot-enroll skip it.

Delete its fork in svrbc/forks when you archive it: work pushed there could never merge. svrbc-audit fails a fork whose project is archived or gone (no-stray-forks).

Unarchive it to work on it again (and svrbc-fork makes it a new fork); it then has to meet the rules like any other project. Archived so far: svaforum.org, and doreanpress.org (which moved to GitHub; its 2021 history is only here).

svrbc/ci-templates holds CI configuration that projects include rather than copy. So far it has npm-publish.yml, which publishes to npm with trusted publishing (OIDC), so there is no NPM_TOKEN to store. Each template starts with a comment explaining how to use it. For this one:

include:
- project: svrbc/ci-templates
ref: master
file: /npm-publish.yml
deploy:
extends: .npm-publish

The Bazel-based CI template described in Builds and CI will go there too.

  • Require two-factor authentication for group members. It is currently off.