TILLFORGE · CI & RUNNERS

Your code, your hardware, your gate.

GA

TillForge CI runs the jobs you declare in .tillforge/ci.yml on runners you host. A push, a pull request, or a manual trigger freezes each job’s spec at the exact commit and queues it; a registered runner claims it, runs every step in its own locked-down container, and reports a check back. Those checks feed the same branch protection and merge queue that guard your branches — CI plugs into the gate, it doesn’t sit beside it. Deploys ride the same runner.

01Config

Declare jobs in .tillforge/ci.yml

Commit a .tillforge/ci.yml at the repo root. It’s read at the exact commit that triggered the run and frozen into the job — a config push mid-queue never retargets a job already waiting. A repo with no file simply runs no CI; a present-but-invalid file surfaces one visible error run rather than failing silently.

yaml
# .tillforge/ci.yml — read at the exact commit, frozen into each job.
# 1–10 jobs; each runs in its own locked-down container on your runner.
jobs:
  test:
    image: node:22-alpine        # any plain container reference (default: alpine:3)
    timeout: 900                 # seconds, 30–3600 (default 900)
    env:                         # up to 20 UPPER_SNAKE keys
      CI: "true"
      DATABASE_URL: "postgres://postgres:pw@db:5432/app"
    services:                    # up to 5 sidecars; the NAME is the hostname
      db:
        image: postgres:16
        env:
          POSTGRES_PASSWORD: pw
        ready: pg_isready -U postgres   # polled inside the service until it exits 0
    steps:                       # 1–50 shell steps, run in order; non-zero fails
      - npm ci
      - npm test
  lint:
    image: node:22-alpine
    steps:
      - npm ci
      - npm run lint
  native:
    image: debian:12
    user: root                   # only for steps that install INTO the image
    steps:
      - apt-get update && apt-get install -y build-essential
      - make
FieldRule
jobs1–10 named jobs; names are 1–32 chars of [a-z0-9._-].
imageAny plain container reference. Default alpine:3.
steps1–50 shell steps, run in order; the first non-zero exit fails the job.
timeoutSeconds, 30–3600. Default 900.
envUp to 20 UPPER_SNAKE keys, values ≤ 500 chars.
servicesUp to 5 sidecar containers, reachable from your steps at the service name. See Services below.
userroot or runner. Defaults to runner — the workspace owner — so /work is writable. Use root only for steps that install into the image.
artifactsUp to 20 workspace paths the runner hashes after a successful run. See Artifact records below.
02Services

Databases and other sidecars

A job that needs a database declares one under services. Each service is a container started before your steps and removed after them, on a private network created for that one run. The service name is its hostname — a db service is reachable at db:5432 and nowhere else. Nothing is published to the host, no service can see your workspace, and a job that fails, times out, or is cancelled still takes its sidecars down with it.

FieldRule
imageRequired. Any plain container reference.
envUp to 20 UPPER_SNAKE keys — how you set POSTGRES_PASSWORD and friends.
readyA shell command run inside the service until it exits 0 — e.g. pg_isready -U postgres. Strongly recommended.
ready_timeoutSeconds to keep polling that command, 1–300. Default 60.
Without a ready command, your steps race the service
A started container is not a ready database. With no readiness command the runner only confirms the service did not immediately die, then starts your steps — which is exactly the shape of a test suite that passes locally and fails one run in five. If a service never becomes ready, the job ends as error rather than failure (none of your code ran) and the service’s own last 50 log lines are copied into the job log, so you see the missing environment variable rather than just a timeout.
03Secrets

Secrets from TillSecrets

A repository admin binds one TillSecrets environment to the repo’s CI. Each job then lists, by name, the keys it needs under secrets. Only those keys are decrypted, only when a runner claims the job, and the values are never stored with the job. Every delivery is recorded in the TillSecrets audit log against the environment.

yaml
# Bind once (repo admin; a restricted environment needs the org owner):
#   tilldev forge ci secrets bind --repo acme-api --env <envId>
jobs:
  release:
    image: node:22-alpine
    secrets:                     # names only — up to 20 keys from the bound environment
      - NPM_TOKEN
      - SENTRY_AUTH_TOKEN
    steps:
      - npm ci
      - npm publish
JobGets secrets?
Push to a protected branchYes.
Manual run or re-runOnly for a commit already on its branch, and only where a push there would qualify.
Pull request from the same repoOnly when the binding is set to all refs.
Anything from a forkNever.

A job whose secrets are refused doesn’t run with them missing: it ends as error before anything is cloned, and the reason is in its log. Values are passed to the container outside the process list and replaced with *** in the log, including each line of a multi-line value. Names that would steer the runner’s own docker client (DOCKER_*, LD_*, PATH and a few others) are refused.

Jobs that receive credentials, meaning declared secrets and every deploy, run only on a secrets runner, and a secrets runner runs nothing else. Register one with tilldev forge ci runners register --name vault-1 --secrets on its own machine. Untrusted code (a member’s pull request, a maintainer’s run of a fork) then never shares a host with a credential, even if it breaks out of its container. Until an org has a secrets runner, those jobs wait in the queue marked awaiting secrets runner.

To keep a job’s checkout in the region your repositories are stored in, register the runner there with --region <region>: it then takes jobs only for repositories stored in that region. A runner without a region takes jobs for any of your repositories.

Masking is a safety net, not a boundary
Code that can read a secret can also encode it past the mask. What keeps secrets safe is who can get code onto a branch that receives them, so keep the binding at protected branches and require review there. Secrets need tillforge-runner 2 or later; an older runner is refused rather than trusted to hide them.
04Container identity

Who your steps run as

By default a job runs as the workspace owner, not root. That makes /work writable — you can build in place instead of copying the checkout somewhere else first — and it means repository-controlled code isn’t running as uid 0. Every capability is dropped except the few a stock image’s entrypoint needs to give up privilege, and no-new-privileges blocks the reverse.

Set user: root on a job whose steps install into the image — apt-get, apk add, pip install, or anything writing to /usr/local/bin. It is per job, so one job needing root doesn’t weaken the rest.

A database image is a service, not a job image
Naming postgres:16 as a job’s image does not give you a database — a job image is the environment your steps run in. Declare it under services instead. If a job image whose entrypoint switches user does fail that way, the log now says so in as many words rather than leaving you with a message about su-exec.
05Runners

Register and run a runner

A runner is a machine you own that executes queued jobs — nothing runs on shared infrastructure. Register one under Forge → Runners to mint a tfr_ token (shown once, stored only as a hash), drop it into the runner’s env file, and start tillforge-runner on an isolated host.

bash
# 1 · Register a runner in the dashboard (Forge → Runners), or
#     tilldev forge ci runners register --name ci-1 [--secrets] [--region <r>]. The tfr_ token
#     is shown ONCE and stored only as a hash. Put it in the runner's env file:

# /etc/tillforge/tillforge-runner.env
TILLFORGE_RUNNER_URL=https://tilldev.dev
TILLFORGE_RUNNER_TOKEN=tfr_xxxxxxxxxxxxxxxx
TILLFORGE_RUNNER_NAME=ci-1

# 2 · Install tillforge-runner on an isolated machine and start it. It claims
#     the oldest queued job for your org, runs each in its own container, streams
#     logs back, and writes a ci/<job> check. With nothing queued it simply waits,
#     polling for work — no jobs run until you push, open a PR, or trigger by hand.
Each job is contained; each run is attributed
Every job runs in its own locked-down container, so a repo’s CI can’t reach across to another’s. A pull-request trigger only auto-runs when the PR author holds write access on the base repository — a fork PR from a read-only member never executes on your hardware until a maintainer runs it deliberately.
Liveness is honest
The Runners screen shows a runner online while it is actively polling; the dot reads real poll activity, so a runner that stopped shows offline within a minute or two.
06The gate

Checks feed branch protection

When a job finishes it writes a ci/<job> check against the commit. Name those checks in a branch’s protection rule under required checks and a pull request can’t merge until they pass. If the branch uses the merge queue, the queue also merges each pull request onto the branch tip as a test commit, runs your jobs on it, and moves the branch to that exact commit only once they pass. This is why CI lives inside the same machinery as reviews and signed commits: one gate, every requirement.

07Triggers

Push, pull request, or on demand

Pushes, pull requests and merge-queue test commits enqueue CI automatically. Trigger a run by hand — for a re-check or a branch that predates the config — from the CLI:

bash
# Runs fire on push and on pull requests automatically. Trigger one by hand:
tilldev forge ci run --repo acme-api                 # default branch tip
tilldev forge ci run --repo acme-api --ref feat/x    # a specific ref

tilldev forge ci list --repo acme-api                # runs, newest first
tilldev forge ci logs <job-id> --repo acme-api       # one run's full log
08Deploys

Deploys ride the same runner

A deployment is a CI-shaped job the same runner claims, clones, and builds — then a final step ships the output through your provider’s CLI (or, for a desktop bundle, signs and publishes native installers). The provider credential is decrypted only at claim time and injected into that one build’s environment, never stored in the job and masked in the log. Because a deploy carries a credential, it runs on a secrets runner, never on the machine that runs your pull requests.

09Artifact records

Trace a file back to the build that made it

Every successful job and every release leaves an artifact record: the commit, the ref, the runner or release that produced it, and the sha256 of each output. List the files a job builds under artifacts and the runner hashes them on its own side once the job’s container has exited, so a step can’t report a checksum of its choosing.

yaml
jobs:
  build:
    image: node:22-alpine
    steps:
      - npm ci
      - npm run build
    artifacts:                   # up to 20 paths in the workspace; * and ? match within one folder
      - dist/app.tar.gz
      - dist/*.whl
RuleDetail
PathsRelative to the workspace, at most 256 characters. No .., no **, nothing under .git.
MatchingA path that matches no file fails the job with that reason, so a broken build can’t pass with nothing recorded.
FilesRegular files only. A symlink anywhere on the path fails the job; folders a pattern matches are skipped.
CapsUp to 50 files per job, 8 GiB per file and 16 GiB in total.
ReleasesA release records each asset that carries a sha256 and links to the builds whose outputs match, whichever came first.
RetentionRecords stay when the job, release or repository is deleted.

Find records under the repository’s Artifacts tab, where you can also choose a file to hash in the browser, or from the CLI. verify hashes the file on your machine, sends only the digest, and exits 1 when no recorded build produced it.

bash
tilldev forge artifacts acme-api                      # records, newest first
tilldev forge artifacts acme-api --commit <sha>       # what one commit built
tilldev forge artifacts show acme-api <id>            # outputs, and the builds or releases linked to it
tilldev forge artifacts verify ./app.tar.gz           # which recorded build produced this file

Each CI record with outputs is then signed as SLSA build provenance by your workspace’s key, and the record shows the level it reached and what stands before the next.

Older runners are refused, not trusted
A job that lists artifacts fails with a visible reason when an older runner claims it, rather than passing with nothing recorded. Upgrade the runner and rerun the job.
10SBOM

Every dependency, from the lockfiles you commit

A software bill of materials lists every package a commit depends on, with its version, package URL, licence and checksum where the lockfile records them. TillForge reads the lockfiles straight from the repository at that commit, not from a build’s workspace, so nothing a job runs can change what the SBOM says. One is made for each successful CI build, each release and each push to the default branch, and while dependency scanning is on, each push to any branch. The build’s artifact record points at it.

EcosystemLockfiles read
JavaScriptpackage-lock.json, npm-shrinkwrap.json, pnpm-lock.yaml, yarn.lock (classic and Berry), bun.lock
Pythonpoetry.lock, uv.lock, pdm.lock, Pipfile.lock, requirements*.txt (pinned == entries)
Go · Rustgo.mod (with replace directives), Cargo.lock
Ruby · PHPGemfile.lock, gems.locked, composer.lock
JVM · .NETGradle lockfiles, packages.lock.json
Apple · DartPackage.resolved, Podfile.lock, pubspec.lock
Elixirmix.lock

Not read: a Maven pom.xml without a lockfile, the binary bun.lockb, and package managers with no lockfile. A lockfile that can’t be read is listed with the reason and the SBOM is marked partial. Up to 64 lockfiles are read per commit, 8 MiB each and 32 MiB in all; copies under folders such as node_modules and vendor are skipped.

Open the repository’s SBOM tab to browse packages, filter by ecosystem or direct and dev-only dependencies, and download the CycloneDX 1.6 JSON. The download is the same bytes every time and its sha256 is shown alongside. To find which repositories use a package, open Dependencies in TillForge, or use the CLI. A workspace can make 30 SBOMs by hand an hour; the automatic ones don’t count.

bash
tilldev forge sbom acme-api                            # the default branch, made now if needed
tilldev forge sbom acme-api --ref v2.1.0 --out sbom.cdx.json
tilldev forge sbom acme-api --packages --scope direct  # or --ecosystem, --filter
tilldev forge deps lodash --ecosystem npm               # which repos use a package
Commits share what they have in common
Commits whose lockfiles are byte-for-byte the same share one stored package list, so an SBOM for every push costs little. Python names match however they’re written: searching Django_REST.framework finds django-rest-framework.
11Vulnerabilities

Known vulnerabilities in every dependency

Every SBOM is checked against the OSV advisory database. TillDev keeps its own copy, refreshed every hour, so your package lists never leave TillDev to be checked. When an advisory is published or changed, every commit that uses the package is checked again. Advisories from several sources that describe one vulnerability show as one finding, under its GitHub advisory ID where there is one, with its CVE and other aliases alongside.

ModeWhat the tilldev/sca check does
Warn (default)Reports findings on each commit and passes.
BlockFails while a finding meets the policy, and merges into protected branches are refused until it is fixed or accepted. A merge also waits while the head commit is being checked.
OffNew commits get no check and recent ones pass. Findings still show in the dashboard.

A finding fails the policy when its severity is at or above the level you choose (critical by default), when it has a fixed version (unless you turn that off), and when the package is a runtime dependency (unless you include dev-only ones). A package known to be malicious always fails. Severity comes from the advisory’s CVSS v3 score where it has one, else its own rating.

Owners and admins can accept a vulnerability: by its ID or any alias such as a CVE, for one package, one repository or both, with a reason, for at most a year. Changing the policy or an exception re-posts the check on recent commits, and both are in the audit log. Check names starting with tilldev/ are reserved, so the API refuses them from anyone else.

The repository’s SBOM tab shows each commit’s findings with the version that fixes them. Vulnerabilities in TillForge lists them across every default branch you can read, with the policy, exceptions and how fresh the advisory data is.

bash
tilldev forge vulns                                    # every default branch you can read
tilldev forge vulns acme-api --wait --blocking         # exits 1 when a finding fails the policy
tilldev forge sca policy --mode block --block-severity high
tilldev forge sca except GHSA-35jh-r3h4-6jhm --package npm:lodash --days 30 --reason "template() is never called"
What isn't covered
CocoaPods has no advisory feed in OSV, so its packages are listed but not checked. A direct push to a branch is checked after it lands; only merges are held back. Advisory data is used under each source’s licence: GitHub, PyPI, Go, the Python Software Foundation and the Erlang Ecosystem Foundation under CC BY 4.0, RustSec under CC0 and OpenSSF malicious packages under Apache 2.0.

Next: Branch protection to require these checks, or Deployments to ship on the same runner. Back to the TillForge overview.