Skip to content

Source code

Every Inky Labs repository is a project in the inkylabs group on GitLab.com. Its subgroup inkylabs/forks is to hold a shared fork of each project (not yet created). An older subgroup, inkylabs/staging (“A staging area for changes”), predates these rules and is not a fork group. GitLab also runs CI (Builds and CI) and can serve developer-facing sites through GitLab Pages (Domains); most of Inky Labs’ sites are on Vercel today.

The group is on GitLab’s free tier, and CI jobs run on the group’s own runners (Runners).

Every Inky Labs 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. A Maintainer merges, once the review bot’s statuses say the owners approved and the tests passed.

Inky Labs’ projects predate these rules, and not all follow them yet: edit-server, kid-years and remark-fancyquote are on main, and a project’s master takes pushes until it is set up as below.

inkylabs-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 inkylabs/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 (inkylabs/forks/qev).

Terminal window
git clone git@gitlab.com:inkylabs/<project>.git # clone the project, not the fork
cd <project>
git switch -c <topic> # …commit…
git fetch origin && git rebase origin/master # merges are fast-forward: start from current master
inkylabs-mr # push to the fork (made the first time), open the merge request

(inkylabs-mr is in this handbook’s tools/.) To release the change as well, inkylabs-mr --ship puts the version bump in it, sets auto-merge and watches it deploy (Releases).

What happens next:

  1. It is built and tested once. On a trusted workstation, inkylabs-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 review bot starts the fork’s pipeline, which does the same (CI in inkylabs/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 a Maintainer merges it, fast-forward, with GitLab’s merge button or Set to auto-merge, once the review bot’s statuses pass. 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 review bot turns into the tag, and the tag’s pipeline deploys (Releases).
  5. The fork’s master catches up: the review 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 Inky Labs. A personal fork leaves with its owner, and its CI runs on the owner’s account; a fork in inkylabs/forks runs on Inky Labs’, with Inky Labs’ 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 Inky Labs (a fork somewhere else) can’t.
Where Branch Push Merge Force-push
inkylabs/<project> master No one Maintainers, once the statuses pass no
inkylabs/<project> * (anything else) No one No one no
inkylabs/forks/<project> master Maintainers (CI’s sync, as inkylabs_bot) Maintainers no
inkylabs/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 requires the pipeline to pass, never counting a skipped one and never rebasing at merge time (automatic rebase, which would merge a commit no pipeline tested). 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.

GitLab deletes a merge request’s branch from the fork only when someone merges it in GitLab; a branch merged any other way stays. The audit warns on any branch left in a fork that is already merged into its master; an owner clears them all with glab api -X DELETE "projects/inkylabs%2Fforks%2F<project>/repository/merged_branches", which leaves protected and unmerged branches alone.

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

Terminal window
P="projects/inkylabs%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 -F allow_merge_on_skipped_pipeline=false -F automatic_rebase_enabled=false

Then, once its CODEOWNERS has merged, hand it to the review bot: a merge request adds the line <project> review to tools/review-bot/projects.txt, and once it is released inkylabs-review-bot-enroll <project> adds the bot’s deploy key and webhooks and checks the settings above. 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’s svrbc.org has one, next, at next.svrbc.org, while master stays svrbc.org; Inky Labs has none). It follows the same three rules, and is protected exactly as master is; projects.txt names it (<project> next=review), and the bot treats it like master, by that branch’s own CODEOWNERS. Changes reach it the usual way, inkylabs-mr --target <branch>; what is ready for production moves on to master by a merge request from the staging branch to master.

inkylabs-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 *. This handbook’s is * @cco3: one owner for the whole project.

Not yet running for Inky Labs (its setup is in infra/review-bot). It is SVRBC’s bot, called the merge bot there until 2026-10-08.

The rule is that a merge request needs approval from the owners of every path it changes. GitLab Free (Inky Labs’ plan) records approvals but never requires them; that is Premium. So people merge, and the bot tells GitLab whether they may: it posts two commit statuses, and Pipelines must succeed holds the merge button until they, and the tests, pass.

  • Approve with GitLab’s Approve button. That is all an owner does. An author who owns what they changed approves it by not marking it a draft: GitLab won’t let authors press Approve, and the merge is their go-ahead. Draft is how to say “not yet”. Approvals given after the latest change count; a rebase that leaves the changes alone keeps them.
  • review-bot/approved is pending until every changed path has its owners’ approval (its description says whose), then success; failed for a CODEOWNERS the bot can’t read. The rules come from the target branch’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 it.
  • The tests: a commit verified on a trusted machine, with no pipeline of its own, gets review-bot/verified, success. Otherwise the bot starts the fork’s pipeline, whose jobs speak for themselves. 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. An external pipeline, the kind GitLab makes for a status posted through its API, never counts as tests.
  • Merge with the button, or Set to auto-merge. Auto-merge is the merge queue. Merges are fast-forward, so each merge leaves the others behind, and GitLab cancels their auto-merge; the bot rebases the first approved one in the queue (never with skip_ci, which would leave GitLab judging a skipped pipeline), sets auto-merge on it again, and GitLab merges it once its statuses pass; then the next. The rest wait unrebased, so a batch of n merge requests costs n pipelines, not one per merge for each. GitLab won’t set auto-merge on one already behind: click Rebase first. One that fails or conflicts leaves the queue, and the bot says why.
  • It keeps one comment on the merge request, edited in place, saying what it is waiting for and where it is in the queue.
  • After a merge 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. A push event starts it; each 15-minute sweep finishes either if a run didn’t.

A guardrail, not enforcement. It stops mistakes: merging before the owners approve or the tests pass. It doesn’t stop someone determined: anyone with Developer can post a status named like the bot’s (the bot names such a status in its comment and posts its own again), and a Maintainer can turn Pipelines must succeed off. Developers are trusted already; getting around the bot on purpose is a breach of these rules, not a workaround.

It acts within seconds of an approval, a push or a pipeline: webhooks start it, and a run every 15 minutes catches anything they missed. The code is tools/review-bot/, and it runs as an AWS Lambda function (review-bot). The projects it looks after are listed in tools/review-bot/projects.txt, with any branch besides master it looks after (<branch>=review, a staging branch). inkylabs-audit fails a project with no CODEOWNERS or no * rule, and checks the settings review mode relies on (merge-checks).

At SVRBC it was first a merge bot: master took pushes only from its deploy key, nobody merged, and the bot merged by pushing once the owners approved. That mode is gone.

Breaking glass. A Maintainer can merge past a stuck status by turning Pipelines must succeed off. Do it only when the bot is down and a fix can’t wait; put it back with inkylabs-review-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. (Not yet for Inky Labs: it is still on.) 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 inkylabs/forks, open merge requests and issues, create projects
Maintainer Also change project settings, and merge once the review bot’s statuses pass
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:inkylabs/<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 inkylabs group, not under your own account, so it never has to be transferred.

  • Public by default. Most Inky Labs projects are public, and this handbook is written on that assumption. Make a project private only when its contents are private, such as personal 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 (inkylabs.com, lofi.inkylabs.com). Name anything else in lowercase kebab case (edit-server, remark-verses). A few older projects predate this (qev, which publishes qev.inkylabs.com); renaming one breaks every clone and link, so it isn’t worth doing just for this.

  • No inkylabs- prefix inside the group: the group already says whose it is (inkylabs/review-bot, not inkylabs/inkylabs-review-bot). Keep the prefix where names are global or shared: AWS resources (Naming), commands on PATH (inkylabs-mr), and local clones in a flat ~/projects/ (~/projects/inkylabs-review-bot).

  • Add an AGENTS.md that points back to this handbook. See Agents files.

  • A new site starts from the template: inkylabs-new-site writes its repository, and its AWS resources and DNS records here, step by step.

  • 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 this handbook’s mark, an ink drop (#1f2a44) between angle brackets (#3b5bdb), in src/assets/logo-light.svg: a placeholder until Inky Labs has a logo of its own. Put it on a solid background: a dark logo on a transparent one disappears in GitLab’s dark theme.

  • Name the same logo in a t3.json at the repository’s root, so T3 Code shows it beside the project as GitLab does. Point iconPath at the committed image the avatar comes from (a site’s public/favicon.svg):

    {
    "$schema": "https://t3.codes/schema/t3.json",
    "iconPath": "public/favicon.svg"
    }

    The image must stand alone: an SVG that draws another file through <image href> (a site’s icons/maskable.svg) shows up blank.

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. inkylabs-audit and inkylabs-review-bot-enroll skip it.

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

Unarchive it to work on it again (and inkylabs-mr makes it a new fork); it then has to meet the rules like any other project. None is archived yet.

CI configuration that projects include rather than copy lives in this handbook’s ci/ (Builds and CI). SVRBC also keeps svrbc/ci-templates, whose npm-publish.yml publishes to npm with trusted publishing (OIDC), so there is no NPM_TOKEN to store. Inky Labs has no such project yet.

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