forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
16.5 KiB
1# CI
2
3forge runs CI workflows defined in `.forge/ci.toml` at the root of a repository. Jobs run on
4separate **runners**, which are `forge runner` on machines you control. The forge never
5connects to a runner. Runners pull jobs over HTTPS.
6
7What v1 has: jobs with `needs`, secrets, artifacts, live logs, commit statuses, and manual, push,
8tag and pull request triggers. What it does not have: a cache, or YAML. The workflow file is TOML.
9
10Jobs run in a rootless sandbox, with no container tool needed. The runner pulls and unpacks the
11image itself, and runs each job in its own container built with libcontainer, the crate behind
12youki. Each job gets its own container, its own workspace, and a read-only token. Podman is still
13supported as an option (`executor = "podman"`).
14
15## Format reference
16
17The file is limited to 64 KiB. Unknown keys are an error at every level (`deny_unknown_fields`).
18A file with a parse error still creates a run, in state `error`, with the message shown on the run
19page and a `ci/config` status set to `error`.
20
21### `[on]`
22
23| Key | Type | Default | Meaning |
24|----------------|-----------------|---------|---------|
25| `push` | array of string | `[]` | Branch globs. A push to a matching `refs/heads/*` starts a run. |
26| `tags` | array of string | `[]` | Tag globs. A push of a matching `refs/tags/*` starts a run. |
27| `pull_request` | bool | `false` | Start a run when a pull request is opened, updated or reopened. The config is read at the **head** commit. |
28| `manual` | bool | `true` | Writers can start a run on an existing branch from the CI tab. |
29
30### `[jobs.<name>]`
31
32There must be at least one job, and at most 20.
33
34| Key | Type | Default | Rules |
35|-------------------|----------------------|---------|-------|
36| `image` | string | none | Required. Non-empty, no whitespace. Image reference, pulled by the runner. |
37| `run` | string | none | Required. Non-empty. Runs as `sh -eu -c "<run>"` in `/workspace`. |
38| `needs` | array of string | `[]` | Job names that must succeed first. Must exist. No cycles. |
39| `secrets` | array of string | `[]` | Repo secret names, each `^[A-Z_][A-Z0-9_]{0,63}$`. Exported as env vars. |
40| `env` | table of string | `{}` | Plain env vars. Names match the same pattern. A secret with the same name wins. |
41| `artifacts` | array of string | `[]` | Relative globs in the workspace. No `..`, no leading `/`. |
42| `runs_on` | array of string | `[]` | Runner labels. Every label must be present on the runner. |
43| `timeout_minutes` | integer | `60` | Range 1 to 360. |
44| `network` | bool | `true` | `false` gives the job an empty network namespace (loopback only). `true` shares the runner host's network namespace. Read [the caveats]#builtin-executor-caveats. |
45
46Job names match `^[a-z0-9][a-z0-9_-]{0,39}$`.
47
48The job's workspace is a fresh checkout of the exact commit, at `/workspace`. The job starts in that
49directory.
50
51### Example
52
53```toml
54[on]
55push = ["main", "release/*"]
56tags = ["v*"]
57pull_request = true
58manual = true
59
60[jobs.test]
61image = "docker.io/library/rust:1"
62run = "cargo test --workspace"
63timeout_minutes = 30
64
65[jobs.package]
66image = "docker.io/library/rust:1"
67needs = ["test"]
68run = """
69cargo build --release
70mkdir -p dist
71cp target/release/forge dist/
72"""
73artifacts = ["dist/*"]
74
75[jobs.deploy]
76image = "docker.io/library/alpine:3"
77needs = ["package"]
78runs_on = ["linux", "x86_64"]
79secrets = ["DEPLOY_KEY"]
80env = { TARGET = "staging" }
81network = true
82run = "sh ./deploy.sh"
83```
84
85Artifacts are stored under the flattened name: `dist/forge` is stored as `dist_forge`.
86
87### Triggers and glob rules
88
89- Branch and tag globs use `*` and `**` with `/` as a separator. `*` matches within one path
90 segment and does **not** cross `/`. `**` matches across segments.
91 - `release/*` matches `release/1.2`, but not `release/1.2/hotfix`.
92 - `release/**` matches both.
93 - `main` matches only `main`.
94- Artifact globs follow the same rule. `out/*.txt` matches `out/a.txt`, not `out/sub/a.txt`.
95- Jobs whose `needs` fail or are skipped are marked `skipped`. A skipped job gets the status
96 `ci/<job>` = `error` with the description `skipped`.
97
98### Commit statuses
99
100Every job reports one status on the commit it ran on. Its context is `ci/<job name>`. A run whose
101config failed to parse reports `ci/config`.
102
103| Job state | Status state | Description |
104|------------|--------------|-------------|
105| queued, blocked, running | pending | |
106| success | success | |
107| failure | failure | |
108| error, cancelled | error | |
109| skipped | error | `skipped` |
110
111Pull request runs from forks, or from authors without write access, wait for approval. Their
112statuses are pending with the description `Waiting for approval`.
113
114## Security model
115
116### Fork pull requests need approval and get no secrets
117
118A pull request run needs approval when its head repository is a fork, or when the author has no
119write access to the base repository. Such a run stays in `waiting_approval`, and no job is queued
120until a writer clicks **Approve and run** on the run page.
121
122Even after approval, a run from a fork gets **no secrets**. The runner receives an empty secret map
123for that job, so `$MY_SECRET` is unset. A job from a fork still gets its plain `env` values, and it
124runs on a runner like any other job, so review the workflow file in the pull request before you
125approve.
126
127### Masking limits
128
129The forge and the runner both mask secret values in logs, replacing each occurrence with `***`.
130Read these limits before you rely on masking:
131
132- Values shorter than 4 characters are **not** masked. A secret of `abc` will appear in the log.
133- Masking covers the raw value and its standard base64 encoding. It does **not** cover any other
134 transformation. A hex, URL-encoded, reversed or truncated copy of a secret is printed as is.
135- A value is masked only where the whole value appears in the output. Printing the secret one
136 character at a time is not caught.
137- Masking is applied while the stream is read, so a value split across two output chunks is still
138 masked.
139
140Treat job logs as sensitive. Never `echo` a secret, even though it is masked.
141
142### Job tokens
143
144Each claimed job gets its own token. The token:
145
146- is **read-only**. It can fetch the job's repository, and its wiki (`<repo>.wiki`), through smart
147 HTTP with the user name `ci-job` and the password `<job id>:<token>`. Every write is refused;
148- only works while the job is `running`. Once the job finishes, the token stops working for logs,
149 artifacts, heartbeat, finish and git reads;
150- is masked in the log, along with the secrets.
151
152### Secrets at rest
153
154Secret values are sealed with the instance key (`forge.key`), like mirror credentials. The secrets
155page shows only names and the time each was last updated. Values are write-only, and writing a
156value is audited by name.
157
158## Runner install
159
160A runner is the `forge` binary, run as `forge runner`. The forge and the runner ship as one
161binary, so an operator deploys one file. The default `builtin` executor needs nothing else.
162
163The `forge-runner` binary is a development and test convenience only. It has the same subcommands,
164but operators should use `forge runner`, which the examples below show.
165
166### 1. Check the host
167
168The builtin executor needs:
169
170- **Linux 5.11 or newer.** Overlayfs inside a user namespace needs it. On an older kernel, jobs
171 still run, but each one gets a full copy of the image instead of an overlay.
172- **Unprivileged user namespaces.** Check that `/proc/sys/user/max_user_namespaces` is above 0. On
173 distributions that gate them, `kernel.unprivileged_userns_clone` must be 1.
174- **cgroup v2 delegation for limits.** The `cpu`, `memory` and `pids` controllers must be delegated
175 to your user. systemd does this for user sessions on current distributions. Without it, jobs run
176 without `[limits]`, and each job log gets a `resource limits unavailable` line.
177
178Run the check:
179
180```sh
181forge runner check
182```
183
184It runs one trivial job through the real sandbox. It prints `ok` or `FAIL` for each requirement,
185with the reason, and exits non-zero when a check fails. Pass `--work-dir` to test the filesystem
186that your jobs will use.
187
188### Subordinate ids
189
190The builtin executor can give a job a range of uids and gids, so that package managers which
191change file ownership (`apt`, `apk add`, `useradd`) work. Inside the job, uid `0` is your user,
192and ids `1` to `n` are your subordinate range, where `n` is the smaller of the uid and gid range
193sizes, at most 65536.
194
195It needs:
196
197- `newuidmap` and `newgidmap`, from the shadow-utils package. They are setuid helpers in
198 `/usr/bin`.
199- An entry for your user in `/etc/subuid` and `/etc/subgid`. The podman command below adds one.
200
201Set the mode with `[sandbox] userns` in `runner.toml`:
202
203```toml
204[sandbox]
205userns = "auto" # "auto" (the default), "subuid", or "single"
206```
207
208- `auto` uses the subordinate range when the host has it. Otherwise it uses your own uid and gid
209 only (single mode), and logs `sandbox ids: single uid ...` at startup.
210- `subuid` requires the range. A host without it fails at startup.
211- `single` always uses your own uid and gid only.
212
213`forge runner check` prints the mode it resolved. In subuid mode it also checks that a job can
214`chown` a file in `/tmp` to `1000:1000`.
215
216### Optional: podman
217
218Set `executor = "podman"` in `runner.toml` to run jobs with rootless podman instead. Install podman
219with your distribution's package manager, for example `sudo dnf install podman`.
220
221Rootless podman needs subordinate user and group ranges for your user:
222
223```sh
224grep "^$USER:" /etc/subuid /etc/subgid
225```
226
227If either line is missing, add a range (it must not overlap any existing one):
228
229```sh
230sudo usermod --add-subuids 100000-165535 --add-subgids 100000-165535 "$USER"
231podman system migrate
232```
233
234Check that podman runs without root:
235
236```sh
237podman run --rm docker.io/library/alpine:3 echo ok
238```
239
240### 2. Keep the runner alive after logout
241
242Systemd user services stop when the user logs out, unless lingering is enabled:
243
244```sh
245sudo loginctl enable-linger "$USER"
246```
247
248### 3. Register the runner
249
250Create a registration token on the forge admin page at `/admin/runners`. It is shown once, works
251once, and expires after `registration_token_hours` (default 24). Then run on the runner machine:
252
253```sh
254forge runner register \
255 --url https://forge.example \
256 --token <token> \
257 --name build-1 \
258 --labels linux,x86_64
259```
260
261This writes `~/.config/forge-runner/runner.toml` with mode 0600, because it holds the runner secret.
262Job workspaces go under `~/.cache/forge-runner/work` unless you pass `--work-dir`.
263
264`--url` must be the forge's public base URL, and the runner must reach it over HTTPS.
265
266The config file looks like this:
267
268```toml
269url = "https://forge.example"
270runner_id = "..."
271runner_secret = "..." # keep private
272name = "build-1"
273labels = ["linux", "x86_64"]
274work_dir = "/home/ci/.cache/forge-runner/work"
275concurrency = 1
276executor = "builtin" # or "podman"
277
278[limits]
279memory = "4g" # the job cgroup's memory.max, or podman --memory
280cpus = "2" # the job cgroup's cpu.max, or podman --cpus
281pids = 4096 # the job cgroup's pids.max, or podman --pids-limit
282image_max_gb = 20 # cap on the unpacked size of one image (builtin)
283
284[images]
285keep_days = 7 # images unused for this many days are deleted (builtin)
286
287[[registry]] # optional login for private images, matched by registry host
288host = "ghcr.io"
289username = "bot"
290password = "..." # keep private, the file is mode 0600
291```
292
293`concurrency` is the number of jobs run in parallel. The default is 1. Each job runs with no
294capabilities and `no_new_privileges`. The builtin executor also runs each job under a seccomp
295profile that denies calls such as `mount`, `ptrace`, `bpf` and `unshare`.
296
297Image garbage collection runs at startup and then daily. Unpacked images are kept under
298`work_dir/oci`.
299
300### Builtin executor caveats
301
302Read these before you run untrusted or sensitive jobs on a runner:
303
304- **`network = true` shares the host's network namespace.** The builtin executor does not set up
305 a separate network, so a job with `network = true` can reach every service that the runner host
306 can reach, including services bound to the host's `localhost`. Use `network = false` for any job
307 that does not need the network. It gets only a loopback interface.
308- **Subordinate ids are mapped in subuid mode.** Container ids `1` to `n` map to your subordinate
309 range, so a command can change file ownership to another user or group, as `apt` and `useradd`
310 do. This needs the setup in [Subordinate ids]#subordinate-ids. Without it, the runner falls
311 back to single mode, where only your own uid and gid exist in a job and such commands fail.
312- **Image files on the host are owned by subordinate ids.** Unpacked images under `work_dir/oci`
313 have the ownership a job sees. A file that is owner `1000` inside a job is owned by
314 `start + 999` on the host, for example `525287` with the range `524288`. `ls -ln` shows the
315 numbers. The runner removes these files through a helper, so it can still clean them up.
316- **With `network = true`, `/sys` is the host's `/sys`,** bind-mounted read-only. The job can read
317 host hardware and kernel details from it.
318- **The job cgroup is placed in the parent slice.** It is named `forge-job-<job id>-<helper pid>`
319 and sits next to the runner's own cgroup, not under it. The runner's cgroup is never used for a
320 job, because stopping a job kills everything in its cgroup.
321
322### 4. Run it under systemd
323
324Create `~/.config/systemd/user/forge-runner.service`, with the `forge` binary in
325`~/.local/bin`:
326
327```ini
328[Unit]
329Description=forge CI runner
330After=network-online.target
331Wants=network-online.target
332
333[Service]
334ExecStart=%h/.local/bin/forge runner run --config %h/.config/forge-runner/runner.toml
335Restart=on-failure
336RestartSec=10
337TimeoutStopSec=120
338
339[Install]
340WantedBy=default.target
341```
342
343Then enable and start it:
344
345```sh
346systemctl --user daemon-reload
347systemctl --user enable --now forge-runner.service
348journalctl --user -u forge-runner.service -f
349```
350
351On shutdown the runner stops claiming, and it cancels and reports any job still running. A job
352whose runner disappears without reporting is failed as `runner lost` after about two minutes, with
353no heartbeat.
354
355### Labels
356
357A job with `runs_on = ["linux", "x86_64"]` is only claimed by a runner that has both labels. Jobs
358with no `runs_on` can go to any runner.
359
360## Forge limits (`[ci]`)
361
362The `[ci]` section of `forge.toml` controls CI on the forge. These are the defaults:
363
364```toml
365[ci]
366enabled = true
367max_log_mb = 16 # a log past this is cut with "[log truncated]"
368max_artifact_mb = 512 # per artifact
369max_artifacts_per_job = 50
370log_retention_days = 90
371artifact_retention_days = 30
372registration_token_hours = 24
373```
374
375Retention runs once a day. Logs older than `log_retention_days` are deleted. The job rows stay, so
376statuses and run pages still work. Artifacts older than `artifact_retention_days` are deleted from the
377blob store.
378
379## Runner protocol
380
381The runner talks to the forge only over HTTPS. JSON bodies are defined in `forge-ci::proto`.
382
383| Method and path | Auth | Purpose |
384|-----------------|------|---------|
385| `POST /api/v1/ci/runners/register` | registration token | Exchange a single-use token for a runner id and secret. |
386| `POST /api/v1/ci/runners/claim` | `Bearer` runner secret | Long-poll (up to about 25 s) for a job. Returns the job assignment, or 204. Disabled runners get 403. |
387| `POST /api/v1/ci/jobs/{id}/logs` | `Bearer` job token | Append a log chunk. The header `X-Forge-Log-Offset` must equal the current size. A mismatch returns 409 with `X-Forge-Log-Size`, and the runner resumes from there. |
388| `POST /api/v1/ci/jobs/{id}/heartbeat` | `Bearer` job token | Sent every 10 s. The reply says whether the job was cancelled. |
389| `PUT /api/v1/ci/jobs/{id}/artifacts/{name}` | `Bearer` job token | Upload one artifact as a raw body. |
390| `POST /api/v1/ci/jobs/{id}/finish` | `Bearer` job token | Report `success`, `failure`, `cancelled` or `error`, with the exit code and an optional message. |
391
392The claim response (`JobAssignment`) carries the repository, the commit, the job's run script,
393image, env, secrets, artifact globs, timeout and network setting. Secrets are empty for fork pull
394request runs.
395
396For the workspace, the runner runs `git fetch --depth=1` on the clone URL with the job token, then
397`git checkout --detach <commit>`. The token goes in an HTTP header, never on the command line.
398
399Browsers and other clients read job logs with `GET /{owner}/{repo}/ci/jobs/{id}/log.txt`. It
400streams the file as it grows and ends when the job finishes.