CI
The same pattern as GitLab runners: a runner pulls jobs, runs them in containers, and reports back. There is no coordinator service; the coordinator is files on DreamHost.
How a job flows
- Queue.
post-receivequeues a job (~/fw/queue/pending/<id>.json) for each pushed branch or tag whose commit has a.fw-ci.yml(or.gitlab-ci.yml) or apackage.json, unlessfuzzyworld.ciisoff.fw ci <name>queues one by hand. - Claim. A runner (
runner/fw-runner.py; on TrueNAS from cron, on a laptop from launchd, every 2 minutes) runsssh dh claim <runner> <tags>. The server moves the job intorunning/with onerename, which is atomic, so two runners never take the same job. - Heartbeat. While a job runs, the runner sends a heartbeat every 30 seconds (
beat <job> <runner>). A job whose runner has been quiet for about 2 minutes (lid closed, network gone) goes back topending/, and a runner that lost its job this way can't mark it done. A runner that sends no heartbeats (an old one) is given 2 hours. - Run. The runner clones the commit and runs each job in a fresh Docker container (
--cpus 4 --memory 4g, 30-minute timeout unless the job setstimeout). - All jobs of a pipeline share one workspace, so artifacts are already there for later stages. - Inside jobs,originis the read-only https clone URL (https://code.fuzzyworld.net/<name>.git), as on GitLab. The runner's ssh key never enters a container. - Report. Logs and
pipeline.jsongo to~/fw/ci/<name>/<sha>/. While a job runs, the runner marks it running and uploads its log every 15 seconds; each upload rebuilds that repo's CI pages and the dashboard (fw ci-pages). When the pipeline finishes, the repo's pages are republished, showing ✓/✗ on the log, the summary and/<name>/ci/.
With no runner at all, jobs stay in pending/ and the CI dashboard says "Waiting for a runner".
Tags
A runner has tags (FW_RUNNER_TAGS, default docker,chrome); a job can ask for more with GitLab's tags:.
- After cloning, a runner that lacks a tag the pipeline needs gives the job back (
release) before running anything. The server then never offers that job to that runner again. - Screenshots need
chrome. - The tag
secretsmeans "this repo's secrets": a job taggedsecretson the default branch or a tag runs only on a runner that hassecrets/<repo>.env. - A job nobody can run waits in the queue.
The runner's key
The runner's ssh key is limited in authorized_keys to server/runner-api (restrict,command="…/runner-api"). It can claim jobs, fetch repos and upload results. It can't get a shell.
Watching CI
- Web:
code.fuzzyworld.net/ci/shows what is running (each job ✓/✗/● with its time), what is queued and the last 50 results. Private repos are never named there, only counted. - Terminal:
fw ci-watchshows everything, private repos included (running jobs with each job's state, the queue, the last 5 results), and ends when nothing is queued or running. - Self-refresh without JavaScript: while anything is queued or running, the dashboard and the running pipeline's page reload every 15 seconds (
<meta http-equiv="refresh">). A thin bar along the top empties as the reload nears, and "Next reload in N s." counts down beside the reload note. Both are CSS only. The countdown needs a browser with@property(otherwise only the bar shows), and the bar is hidden forprefers-reduced-motion.
Job definitions: .fw-ci.yml
A file at the repo root, in a subset of GitLab CI's YAML format, so an old .gitlab-ci.yml works after a rename
(git mv .gitlab-ci.yml .fw-ci.yml). Until a repo is renamed, the runner still reads .gitlab-ci.yml; .fw-ci.yml
wins if both exist.
| Supported | Keys |
|---|---|
| Top level | stages, variables, image (string or name:), default: (image, before_script, after_script), workflow:rules |
| Reuse | extends (hidden jobs) |
| Per job | stage, image, script, before_script, after_script, variables, rules (if, when, exists; changes always matches), only/except (refs), when (on_success, on_failure, always, manual, never), allow_failure, timeout, needs (ordering comes from stages), tags |
| Ignored (change nothing here) | artifacts, cache, dependencies, environment, interruptible, retry, coverage, resource_group |
Anything else (for example include, services, release, trigger, parallel) makes that job fail with a
message naming the key; nothing is silently skipped. manual jobs are listed but not run.
- The usual
CI_*variables are set (CI_COMMIT_SHA,CI_COMMIT_BRANCH/CI_COMMIT_TAG,CI_COMMIT_REF_SLUG,CI_DEFAULT_BRANCH,CI_PIPELINE_SOURCE=push, ...), plusFUZZYWORLD_CI=true. - Jobs without an image use
node:22-bookworm. - A
pagesjob runs its build as a check; publishing isfw deploy's job.
Without a pipeline file
- An app (
package.jsonwithfuzzyworld.app) gets one job,fw deploy --here --dry-run(install, prebuild, checks, build, output checks). - Other node repos get their
typecheck/lint/testscripts.
Check what a checkout would run
runner/fw-runner.py --plan <dir> [refs/heads/<branch>]
Secrets
<runner dir>/secrets/<repo>.env on each runner that should deploy (TrueNAS: /mnt/main-2025/fw-runner/secrets/;
laptop: ~/.fw-runner/secrets/). The file is passed (--env-file) only to jobs on the default branch and tags.
- A runner without the file still runs that repo's other jobs. Tag a deploy job
secretsto keep it for a runner that has the file. - Without any secrets, jobs that deploy elsewhere (for example
dreamhost's rsync, engye's store uploads) fail. - Anything on the laptop is as safe as the laptop (FileVault on). If it is lost, rotate the secrets.
The laptop as a runner
runner/install-laptop.sh (or bin/deploy-tools laptop-runner) installs a launchd job that runs every 2 minutes
with FW_RUNNER_POLITE=1. A polite runner claims a job only when:
- the Mac is on AC power,
- at least
FW_RUNNER_MIN_IDLE(default 50) percent of the CPU is idle, and - Docker is running.
It checks before each job, never during one. Its containers get a low CPU share (FW_RUNNER_CPUS, default 2 there,
is the real limit).
| Item | Where |
|---|---|
| Settings | ~/.fw-runner/runner.env |
| Log | ~/.fw-runner/runner.log |
| Allow its key on DreamHost | runner/install-laptop.sh --authorize |
| Stop it | runner/install-laptop.sh --remove |
It assumes the laptop is on for a few hours a day, which is enough when TrueNAS isn't there.