Wiki: CI
CI: what a push queues
Three mechanisms decide what a push does to CI, and they interact:
- Dedupe. A job's result is a property of the commit's tree. A commit
that already has a passed, queued or running build for a job is not
queued again; a commit whose tree already passed a job gets that
result as its status, naming the build it came from (#177). Only a
trusted build counts, and for tree reuse only one on the image the
job names: a fork's green build does not stand for the repository's
own, so its commit is built again when it lands on a branch (#258). A
job that names no
image:is compared as naming none: its reuse does not notice the runner's default image changing, because reuse is decided when the push is queued, before any runner claims the build, and runners can differ in their default. Name the image inci.ymlto tie reuse to it; after an operator changes a runner's-image,build triggerbuilds a job afresh, since a triggered build is never reused. When a trusted and an untrusted build of the same commit both run, the one that finishes last setsci/<job>on that commit. A failed, cancelled or abandoned build does not count: that commit runs again. - Path filters.
pathsandpaths-ignoreon a job are evaluated against the files the push changed. The diff base is the old tip when it is an ancestor of the new one, and the merge base with the default branch otherwise: a new branch, or a force push after a rebase (#176). A filter that cannot be evaluated, because there is no diff base or the diff fails, runs the job rather than skipping it. A filter that excludes the push records askippedstatus, whichrequire-checksaccepts (#172). - The reaper. A build a runner claimed and never reported is failed by
the scheduler's tick: two minutes after its log stream ended, or at
the build deadline if no stream was ever seen (#179). Its status reads
build abandoned.
Which runner takes a build is the fourth: a build is claimed only by a
runner attached to its repository (or one polling with a full-scope
admin key), and an untrusted build only by one started with
-untrusted. A repository with no runner attached queues builds
nothing claims. See the Users page. Among what a runner may claim it
takes the oldest pending build, across every repository it serves;
a repository that must never wait on another gets a runner of its own
(decided in krz/gitbay#207). There is no cap on how many
builds an account queues or how often a schedule fires, and none is
planned (krz/gitbay#206): a tick queues nothing while the job's last
build is pending or running, so a repository with no runner holds one
row per scheduled job, and a schedule with a runner attached spends
its owner's compute, not the instance's.
Scheduled jobs run on their cron against the default branch, never on push; a default-branch push registers or updates them. Tag jobs run on a matching tag push and nothing else.
A running build is followed with build log <owner/name> <n> --follow:
the stored log, then output as the runner sends it, then the outcome
as build <n> <status> on stderr once the build ends. The exit code is
0 whatever the outcome. A follow of a build still queued after ten
minutes ends on its own and says so on stderr, since nothing reaps a
queued build. The build page does the same without JavaScript while a
build is queued or running; ?follow=0 renders it once. An account
holds at most eight follows open, and signed-out viewers share one
account's eight. Over the JSON API the command answers when the build
ends, with the whole log. Read access is checked again every two
seconds: a follower who loses it is told the repository is not found.
A restart ends every open follow with a message saying so, rather
than holding the drain; follow again once the daemon is back.
A failed build names the step it stopped at: the log's last line reads
step 3/3 failed: exit 1, and build show prints failed step (3/3
go test ./... (exit 1)) and duration. build log <owner/name> <n>
--step failed prints only that step's output, --step 2 another one
(0 is the clone before the first step), and --tail 40 the last forty
lines of whichever was chosen; neither combines with --follow. The
build page folds the finished log into one section per step, opens the
failed one and links to it from the top as "Jump to failure". Builds
from before this reported no step; their last section is taken as the
failed one.
What a build can reach
| Build | Internet | Runner's host | Private ranges |
|---|---|---|---|
| trusted | open | forge's public 22, 80, 443; DNS on loopback | closed |
| untrusted | TCP 80, 443; DNS | DNS on loopback | closed |
An untrusted build is a merge request head from a fork. It can fetch
modules and packages over HTTPS but cannot reach the forge, send mail,
or open ssh elsewhere. The forge is reached at the address in
GITBAY_SSH: on a runner that polls the daemon over loopback,
169.254.1.2, which pasta translates to the host's public address, so
a build's logins never arrive from 127.0.0.1, where the runner polls.
Two nftables tables on the runner's host enforce this, one by the
runner's user and one by the cgroup each build runs in; the Threat-Model
page, "What a build can reach", says how and why, and the Admin page
how to install them (krz/gitbay#260). A runner off the daemon's host
gets none of this unless its operator adds it.
To check a runner, run deploy/runner-auth-flood-test.sh against a
scratch repository, once as a push and once with --untrusted: the
build probes what it reaches, then fails SSH logins with an expired key
until the limiter locks its address, and the script checks that the
runner still reported the build and kept polling, and that the build's
log shows what the table allowed and refused.
Measured on bay1
2026-09-29, runner and daemon at 8b73af2, kernel 6.12.107, nftables
1.1.3, podman 5.4.2, the runner scoped to cmc/runner-scratch. pasta
ran in the build's cgroup both times
(builds/trusted/build-2144, builds/untrusted/build-2146). Both runs
passed.
| Probe | trusted | untrusted |
|---|---|---|
| DNS | ok | ok |
| 169.254.1.2:22/80/443 | open | refused |
| 169.254.1.2:2222 | refused | refused |
| 10.0.0.1:80, 192.168.0.1:80 | refused | refused |
| proxy.golang.org:443 | open | open |
| github.com:22 | open | refused |
| 12 logins | 12 denied (sshd) | 12 refused (table) |
The trusted build's logins arrived from the host's public address,
46.232.248.67: ten auth.expired and one auth.throttled for it, none
for 127.0.0.1. The untrusted build's logins never reached sshd. The
runner kept polling through both builds.
The table
One ci.yml with five jobs, each isolating one rule:
jobs:
plain:
steps: [echo plain]
onapp:
paths: [app/**]
steps: [echo onapp]
notapp:
paths-ignore: [app/**]
steps: [echo notapp]
nightly:
schedule: "0 3 * * *"
steps: [echo nightly]
release:
tags: "v*"
steps: [echo release]
Every push below changes app/x and nothing else, so a push whose
filters are evaluated queues onapp and skips notapp, and a push
whose filters cannot be evaluated queues both. Each cell is what the
push produced for that job; the commit's ci/<job> status follows:
queued: a build, and apendingstatus until the runner reports.queued, untrustedis a build without the repository's secrets.skipped: no build, and askippedstatus naming the filter.reused: no build, and asuccessstatus naming the earlier build with the same tree.registered: no build; the job's schedule is (re)registered.abandoned: the build failed and the status readsbuild abandoned.—: nothing new. A status the commit already had stands.
| push | plain | onapp | notapp | nightly | release | ci/config |
|---|---|---|---|---|---|---|
| first push of the default branch | queued | queued | queued | registered | — | — |
| push to the default branch | queued | queued | skipped | registered | — | — |
| push to another branch | queued | queued | skipped | — | — | — |
| a new branch, no old sha | queued | queued | skipped | — | — | — |
| rebase onto a moved default branch, old not an ancestor | queued | queued | skipped | — | — | — |
| rewritten commit, same tree as a passed build | reused | reused | skipped | — | — | — |
| fast-forward of a commit built on another branch | — | — | — | registered | — | — |
| a commit whose earlier build failed, on a new branch | queued | queued | — | — | — | — |
| merge request head from a fork | queued, untrusted | queued, untrusted | queued, untrusted | — | — | — |
| tag push | — | — | — | — | queued | — |
| schedule tick on the default branch | — | — | — | queued | — | — |
| schedule tick while the last scheduled build is still pending | — | — | — | — | — | — |
| claimed builds whose runner vanished | abandoned | abandoned | — | — | — | — |
| push to the default branch with an old sha that cannot be diffed | queued | queued | queued | registered | — | — |
| push with a broken ci.yml | — | — | — | — | — | failure |
| push with no ci.yml | — | — | — | — | — | — |
Rows worth a second look:
- The first push of the default branch has no diff base at all: the merge base of the tip with itself is the tip. Every filtered job runs. The same holds when the old sha cannot be diffed on the default branch; on any other branch the merge base takes over and the filters apply.
- A rewritten commit with the same tree reuses
plainandonappbecause the tree check comes before the filter.notappnever had a passed build to reuse, and the push changed nothing, so the filter skips it again. - A fast-forward of a commit built elsewhere queues nothing: the builds belong to the commit, not the branch. The default-branch push still registers the schedule.
- A failed build does not stand for its commit. The same commit pushed
to another branch runs again;
notappkeeps its skipped status. - A merge request head from a fork has no diff base and, unlike a
branch push, no merge-base fallback: every job runs, without secrets.
Filtering a head down to no jobs would make it unmergeable under
require-checks(#172). - A context named in
repo settings require-contextsmust be one that reports on merge request heads. A schedule-only or tag-only job never reports there, so the gate stays pending (#258).
TestPushShapes in internal/hookd runs every row against real git
and the store, and TestPushShapesTableOnWiki checks that this page
carries each row as the code has it. A row that changes fails there
first.