Your code, your hardware, your gate.
GATillForge 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.
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.
# .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| Field | Rule |
|---|---|
jobs | 1–10 named jobs; names are 1–32 chars of [a-z0-9._-]. |
image | Any plain container reference. Default alpine:3. |
steps | 1–50 shell steps, run in order; the first non-zero exit fails the job. |
timeout | Seconds, 30–3600. Default 900. |
env | Up to 20 UPPER_SNAKE keys, values ≤ 500 chars. |
services | Up to 5 sidecar containers, reachable from your steps at the service name. See Services below. |
user | root or runner. Defaults to runner — the workspace owner — so /work is writable. Use root only for steps that install into the image. |
artifacts | Up to 20 workspace paths the runner hashes after a successful run. See Artifact records below. |
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.
| Field | Rule |
|---|---|
image | Required. Any plain container reference. |
env | Up to 20 UPPER_SNAKE keys — how you set POSTGRES_PASSWORD and friends. |
ready | A shell command run inside the service until it exits 0 — e.g. pg_isready -U postgres. Strongly recommended. |
ready_timeout | Seconds to keep polling that command, 1–300. Default 60. |
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.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.
# 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| Job | Gets secrets? |
|---|---|
| Push to a protected branch | Yes. |
| Manual run or re-run | Only for a commit already on its branch, and only where a push there would qualify. |
| Pull request from the same repo | Only when the binding is set to all refs. |
| Anything from a fork | Never. |
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.
tillforge-runner 2 or later; an older runner is refused rather than trusted to hide them.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.
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.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.
# 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.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.
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:
# 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 logDeploys 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.
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.
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| Rule | Detail |
|---|---|
| Paths | Relative to the workspace, at most 256 characters. No .., no **, nothing under .git. |
| Matching | A path that matches no file fails the job with that reason, so a broken build can’t pass with nothing recorded. |
| Files | Regular files only. A symlink anywhere on the path fails the job; folders a pattern matches are skipped. |
| Caps | Up to 50 files per job, 8 GiB per file and 16 GiB in total. |
| Releases | A release records each asset that carries a sha256 and links to the builds whose outputs match, whichever came first. |
| Retention | Records 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.
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 fileEach 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.
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.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.
| Ecosystem | Lockfiles read |
|---|---|
| JavaScript | package-lock.json, npm-shrinkwrap.json, pnpm-lock.yaml, yarn.lock (classic and Berry), bun.lock |
| Python | poetry.lock, uv.lock, pdm.lock, Pipfile.lock, requirements*.txt (pinned == entries) |
| Go · Rust | go.mod (with replace directives), Cargo.lock |
| Ruby · PHP | Gemfile.lock, gems.locked, composer.lock |
| JVM · .NET | Gradle lockfiles, packages.lock.json |
| Apple · Dart | Package.resolved, Podfile.lock, pubspec.lock |
| Elixir | mix.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.
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 packageDjango_REST.framework finds django-rest-framework.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.
| Mode | What the tilldev/sca check does |
|---|---|
| Warn (default) | Reports findings on each commit and passes. |
| Block | Fails 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. |
| Off | New 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.
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"Next: Branch protection to require these checks, or Deployments to ship on the same runner. Back to the TillForge overview.