CI runner
CI runs a pipeline from a file in a Git repository. A machine you control runs vesken_runner, claims the job, and executes it. The platform does not build the pipeline itself.
Open the repository in the console. Runners registers machines. Runs lists pipeline runs, each step, and the stored log. Finished runs keep their logs. A merge request shows the latest pull-request run, including whether each test suite passed.
Register a runner
Section titled “Register a runner”Runners belong to one repository.
- On the repository, open Runners and create one. Name it after the machine.
- Copy the token. It is shown once and starts with
kfxr_. The platform stores only a hash. - On that machine, start the runner (Dart SDK ^3.10, Docker, git, and ssh):
export VESKEN_RUNNER_URL=https://api.tefily.comexport VESKEN_RUNNER_TOKEN=kfxr_…cd packages/vesken_runner && dart run bin/vesken_runner.dartThe process heartbeats. Runners shows it online when the last heartbeat is within one minute. Revoke deletes the token; the process then gets 401 and stops claiming work.
A repository can have 20 repository runners. A project can have 20 project runners and 20 organization runners, counted on the project that created them. Project runners take jobs from every repository in that project. Organization runners take jobs from every repository in that organization. Only an organization owner or admin can register an organization runner. repos.push on the project is enough for a repository or project runner.
A runner has tags, run untagged (on by default), protected, and paused. A job with no tags is taken only by a runner that runs untagged jobs. A job with tags is taken only by a runner whose tags include every job tag. A protected runner only takes jobs whose branch is protected on that repository. A paused runner keeps heartbeating and does not claim.
--concurrent (or VESKEN_RUNNER_CONCURRENT, default 1) starts that many claim loops in one process. Each claim is still one job. The oldest queued job (created_at) that matches the runner is taken. Stage and needs still decide when a job becomes queued.
The runner process is the only thing that needs Dart on that machine. test and run steps execute inside the job’s container image, so Flutter, Node, or any other SDK comes from image, not from the host. deploy-static runs on the host after the container exits and only calls the hosting API.
Pipeline file
Section titled “Pipeline file”Commit .kataflax/ci.yaml at the repository root. No file means no run.
on: branches: [main] pull_request: truejobs: - name: frontend image: instrumentisto/flutter:3.38.10 paths: - packages/frontend - packages/shared steps: - test: flutter test workingDirectory: packages/frontend - run: flutter build web --dart-define=KATAFLAX_API_URL=https://api.tefily.com workingDirectory: packages/frontend - deploy-static: appId: <static-app-id> path: packages/frontend/build/web when: branch| Key | Behavior |
|---|---|
on.branches |
Run when that branch’s HEAD moves (push, or a merge into it). Other branches, including editor workspace branches (lastare/…), do not run unless listed. |
on.pull_request |
Run when a merge request is opened and again when its source branch moves. A map {merge_result: true} does the same and checks out the merge with the target branch. |
on.tags |
true, or a list of tag names. A name may include * (one or more digits) or ? (one digit); frontend-build-* matches frontend-build-12. A moved tag enqueues a run with CI_EVENT=tag. The run’s branch field is the tag name. |
on.schedules |
List of {cron, branch}. Cron is five UTC fields. The billing sweep enqueues a schedule run for that branch’s HEAD. The entry is read from that branch’s pipeline file. |
jobs[].image |
Container image for that job’s test and run steps. The clone is mounted at /workspace. |
jobs[].paths |
Optional path patterns. Shorthand for one rules entry with changes. The job runs only when a changed file matches. Omit both paths and rules to run the job on every pipeline. |
jobs[].variables |
String map passed to that job’s step containers as environment variables. |
jobs[].shell |
Program that runs the step script. Default /bin/sh, so Alpine images work. The runner writes the command to a file in the workspace and executes that file. |
jobs[].pull |
always, if-not-present, or never. Omitted leaves Docker’s default pull behavior. |
jobs[].user |
Docker --user for the step container, such as 1000:1000. |
jobs[].entrypoint |
Docker --entrypoint. When set, this program runs the step script instead of shell. |
jobs[].cpus |
CPU limit passed to Docker --cpus. |
jobs[].memory |
Memory limit passed to Docker --memory, such as 512m. |
jobs[].shmSize |
Shared memory size passed to Docker --shm-size, such as 64m. |
jobs[].extraHosts |
List of host:ip entries passed to Docker --add-host. |
jobs[].privileged |
true adds --privileged. The step fails unless the runner was started with --docker-privileged. |
jobs[].volumes |
Extra Docker mounts. The step fails unless the runner was started with --docker-volumes. The workspace mount is always present. |
jobs[].devices |
Devices passed to Docker --device. The step fails unless the runner was started with --docker-devices. |
jobs[].gpus |
Value passed to Docker --gpus, such as all. The step fails unless the runner was started with --docker-gpus. |
jobs[].timeout |
Minutes for this job. The step budget is the tighter of this value, the runner --run-timeout, and the platform maximum of 120 minutes. |
stages |
Ordered stage names. Jobs in a stage start together after every required job in the previous stage has succeeded or been skipped. Omit it and the order is the first time each stage appears. A job with no stage is test. |
jobs[].stage |
Stage name. Default test. |
jobs[].needs |
Job names that must succeed, or fail with allow_failure, before this job can run. needs replaces stage ordering for that job. A skipped need does not count as success or failure. |
jobs[].allow_failure |
A failed job does not fail the pipeline, and it still unblocks needs. |
jobs[].retry |
How many times a failure requeues this job. Each retry is a new clone. Default 0. |
jobs[].when |
on_success (default), on_failure, always, or manual. on_failure runs after a needed job or the previous stage fails. always runs on success or failure. manual waits for Play on the run screen. |
jobs[].before / jobs[].after |
Shell commands. before runs first. after runs even when before or the main steps fail. The job status follows before and the main steps. An after failure does not fail a successful job. |
jobs[].tags |
Runner tags must include every one of these. A job with no tags is taken only by a runner that runs untagged jobs. |
jobs[].rules |
List of {if, changes, when}. The first match wins. if may set event, branch, and tag. event and branch are exact. tag uses the same exact name or digit pattern as on.tags. changes uses the same patterns as paths. No match skips the job. A matching when replaces the job when. |
include |
Repo-relative YAML files, read from the same commit and merged before validation. One level deep. Remote URLs are rejected. |
jobs[].extends |
Name of another job in the merged file. The child inherits that job’s keys. A key the child sets replaces the parent value. |
jobs[].template |
true hides the job. It can be extended and is not enqueued. It may omit image and steps. |
jobs[].parallel.matrix |
Map of lists. Each combination is its own job, named name: [value, value], with those values as environment variables. At most 50 combinations. |
jobs[].interruptible |
true cancels this job when a newer pipeline is queued for the same branch, if it is still queued, waiting, or running. Default false. |
jobs[].resource_group |
A name on the repository. Only one running job in the repository may hold it. Other jobs that need it stay queued. |
jobs[].artifacts.paths |
Repo-relative files or directories uploaded after after scripts, including when the main steps failed, when those files exist. A later job unpacks them before its own before scripts. |
jobs[].artifacts.expireIn |
How long the archive is kept. Examples: 7d, 24h, 30m. Default 7 days. Maximum 90 days. |
jobs[].artifacts.reports.junit |
Path to a JUnit XML file. The file is packed into the archive. The run shows how many tests passed, failed, and were skipped. Malformed XML is a log warning and does not fail a job that succeeded. |
jobs[].dependencies |
Job names whose artifacts to unpack. Omit it to use needs, or every job in the previous stage. An empty list unpacks nothing. |
jobs[].cache.key |
Name of a repository cache. A miss is logged and does not fail the job. |
jobs[].cache.paths |
Repo-relative paths saved after the job, including when the job fails. A cache upload failure does not fail a successful job. |
jobs[].services |
Containers started beside the job on a private network, then removed when the job ends. Each entry is an image name, or a map with name, optional alias, variables, command, entrypoint, and healthcheck. |
services[].alias |
Hostname the step uses to reach that service. If omitted, it is the image name with the registry path and tag removed (postgres:16 becomes postgres). |
services[].variables |
Environment for that service container only, such as POSTGRES_PASSWORD. |
services[].command |
Command after the service image. |
services[].entrypoint |
Docker --entrypoint for that service container. |
services[].healthcheck |
Command the runner repeats with docker exec /bin/sh -c until it exits 0. |
clone |
Optional on the pipeline or a job. strategy is clone (default) or fetch into a persistent checkout. depth is a positive integer. submodules is off (default) or recursive. lfs: true runs git lfs pull when git-lfs is installed on the runner host. fetch still checks out the claimed commit. A job clone overrides the pipeline field by field. |
test |
Shell command recorded as a test suite. Non-zero exit fails the run and skips later steps. Output stays in the step log. |
run |
Shell command. Same failure behavior as test. |
workingDirectory |
Directory inside the clone. Default is the repository root, so monorepo path dependencies still resolve. |
deploy-static |
Tar the path directory and publish it as a static site deployment for appId. The archive must already be the site (index.html at the root), not source. |
deploy-static.when: branch |
Skip this step on pull-request runs, so a merge request can build and report status without replacing the live site. |
Patterns in paths and rules.changes are relative to the repository root. * matches inside one directory, ** matches across directories, and ? matches one character other than /. A pattern with no wildcard matches that file or directory and everything under it, so packages/frontend matches packages/frontend/lib/main.dart.
include files are concatenated in order, then the root file. A job extends a job from either file. template: true jobs are removed after that merge. parallel.matrix then becomes one job per combination, and needs that named the original job name point at every combination.
A branch run compares the files in that push. A newly created branch has no previous commit, so every job runs. A pull-request run compares the whole merge request with its target branch, so a later commit still runs a job when an earlier commit on the request changed a watched file. A manual rerun of a branch pipeline runs every job. A manual rerun of a pull-request pipeline uses the merge-request comparison again. If no job matches, nothing is queued.
One run is queued per repository, commit, and event (branch or pull_request). A new commit queues another run. Each YAML job is claimed on its own and cloned on its own. Steps inside that job share the clone. Jobs do not share a workspace. A runner takes the oldest queued job whose stage or needs are already satisfied and whose tags, scope, and protected flag match that runner.
Each run has a 20 minute limit by default (--run-timeout on the runner). A job timeout can make that job shorter. Neither value can run past the platform maximum of 120 minutes (CI_RUN_MAX_MINUTES). The runner kills the step container when the tighter limit is reached.
test and run write the command to a script in the workspace and run it with /bin/sh, or with jobs[].shell. If the image has no such program, the step log says the image cannot run that shell. deploy-static still runs on the host.
A runner can refuse images with --allowed-images. An empty list allows every image. Privileged mode, extra volumes, devices, and GPUs stay off unless the runner was started with --docker-privileged, --docker-volumes, --docker-devices, or --docker-gpus. A job that asks for one of those without the matching flag fails before docker run.
--executor chooses where shell steps run (VESKEN_RUNNER_EXECUTOR, default docker). docker is the behavior above. shell runs sh on the runner host, in the clone directory, with the same variables, artifacts, cache, and before / after scripts. It does not pull an image and does not run docker. A job that declares services fails before its steps. kubernetes needs --kubeconfig and --kube-namespace (VESKEN_RUNNER_KUBECONFIG, VESKEN_RUNNER_KUBE_NAMESPACE). Each job is one Pod in that cluster: a build container, one container per service, and a shared emptyDir mounted at /workspace. The runner watches the Pod, streams its step output, and deletes the Pod when the job finishes, when the job is cancelled, or when the runner drains. This is the runner’s own cluster. There is no executor that lends a short-lived Docker host.
SIGINT or SIGTERM stops the runner from claiming another run, kills the active step container and its service containers, and fails the run. If the current step cannot finish, its log contains runner shutting down. A second signal exits immediately.
--metrics-addr host:port serves Prometheus text for jobs running, claim errors, and the last heartbeat. Leave the flag unset to disable it.
A job that needs Postgres, Mongo, or Redis lists them under services. The runner starts those containers before the job’s first step and deletes them when the job ends, including when a step fails. Step containers join the same network and reach each service at its alias. localhost inside the step container is the step itself. Service images do not install their programs into the step image. With a healthcheck, the runner repeats that command until it exits 0. Without one, it waits until each exposed TCP port accepts a connection. A service with neither exposed ports nor a healthcheck is ready as soon as it has started, and the log says so. The job fails if the service is not ready before the run times out.
- name: backend image: dart:stable variables: PGHOST: postgres services: - name: postgres:16 alias: postgres variables: POSTGRES_PASSWORD: postgres POSTGRES_DB: app_test steps: - test: dart testLimits
Section titled “Limits”- Runs per day. Free allows 50 runs per organization per UTC day (Pro 1,000, Scale 5,000). A push over the limit still appears in Runs, as a Skipped run with the reason “daily CI run limit reached”, and never starts. Skipped runs do not count against the limit. Rerun over the limit returns
429 quota_exceeded. - Runners. Up to 20 per repository.
- Log size. Each step keeps up to 10 MB of log; the rest is dropped with a
[log truncated…]marker. Logs are deleted after the plan’s log retention period (the run and step results stay). - Time limit. A run whose runner stops heartbeating for 10 minutes after the maximum run time (
CI_RUN_MAX_MINUTES, default 120) is marked failed. - Usage. Run time is recorded as
ci_minutes(rounded up to whole minutes, at least 1) for usage reporting. It is self-hosted compute, so it is not billed or limited.
Plan fees and included quotas: Pricing.
Static deploys
Section titled “Static deploys”deploy-static uses the existing static upload API. It does not run flutter build for you, and it does not use hosting git auto-deploy.
Leave the static app’s git deploy mode unset or manual if this pipeline publishes the built site. An auto link on the same app would extract the git tree as the website and replace the built files.
Compile-time values such as --dart-define belong in the run command. Hosting secrets are runtime environment variables for container apps, not inputs to this build.
Variables
Section titled “Variables”Repository Variables (next to Runners) are encrypted at rest with the same key as hosting secrets. The API never returns the value after it is saved.
| Flag | Behavior |
|---|---|
| Masked | The value is replaced with [REDACTED] in step logs before they are stored. |
| Protected | Sent only when the run’s branch is protected on the repository. |
| File | The runner writes the value to a file in the workspace and sets the variable to that path (/workspace/.vesken-runner/ci-files/<key>). The file is deleted when the workspace is cleaned up. |
YAML jobs[].variables override repository variables of the same name. Names starting with CI_ are reserved: YAML cannot replace them.
Every job container receives:
| Name | Value |
|---|---|
CI_COMMIT_SHA |
Commit being built |
CI_COMMIT_BRANCH |
Branch name |
CI_PIPELINE_ID |
Run id |
CI_JOB_NAME |
YAML job name |
CI_JOB_ID |
Id of the claimed job |
CI_PROJECT_ID |
Project id |
CI_REPO_ID |
Repository id |
CI_EVENT |
branch, pull_request, tag, or schedule |
CI_JOB_TOKEN |
JWT (type: ci_job) valid for 2 hours. Not revoked when the job finishes. |
A claim is one job, so CI_JOB_NAME and CI_JOB_ID stay the same for every step of that claim. Service containers receive only that service’s variables, not repository variables or CI_* values.
CI_JOB_TOKEN (type: ci_job) can GET this repository’s blob (repository:read). It can upload this job’s artifacts and cache (artifacts:write) and download artifacts from jobs in the same run plus this repository’s cache (artifacts:read). It cannot administer the project, and it is not the static-deploy token. The token lasts 2 hours. Auth checks the JWT expiry and ciJobPathAllowed. There is no denylist and no revoke-on-complete, so the token stays valid until it expires, including after the job finishes. It still cannot call paths outside that allow-list. Archives live in the artifact bucket under ci/{projectId}/{repositoryId}/…, not in the static-site bucket. Expired job archives are deleted by the same sweep that prunes hosting artifacts.
Job archives honor expireIn (default 7 days, maximum 90 days), and purgeExpired deletes those objects. Cache does not. A cache object is ci/{projectId}/{repositoryId}/cache/{sha256}.tar.gz in the artifact bucket. Uploading the same cache key overwrites that object. There is no cache TTL and no cache purge. Distinct keys stay and count toward volume storage (storage_mb). A cache upload that does not fit is deleted and does not fail a successful job.
Open the repository, then Runs, then a run. Cancel or Rerun that run, or Play a manual job.
Console routes use a platform JWT or PAT. Prefix: /api/projects/:projectId/git/repos/:repoId.
| Method | Path |
|---|---|
| GET, POST | /ci/runners |
| PATCH, DELETE | /ci/runners/:runnerId |
| GET, POST | /ci/variables |
| PUT, DELETE | /ci/variables/:variableId |
| GET | /ci/runs, /ci/runs/:runId |
| GET | /merge-requests/:mrId/ci-run |
| POST | /ci/runs/:runId/rerun |
| POST | /ci/runs/:runId/cancel |
| POST | /ci/runs/:runId/jobs/:jobId/play |
Rerun (repos.push) reads .kataflax/ci.yaml at the same commit and queues a new run (201). A branch rerun runs every job. A pull-request rerun uses the merge-request comparison again. Over the daily cap, rerun returns 429 quota_exceeded.
Cancel is allowed while the run is queued or running. A queued run is never claimed. A running run is marked cancelled immediately; the runner sees cancelRequested on the next heartbeat and stops the step container. A finished run returns 409. Cancelling an already cancelled run returns the run.
Play is only for a job in status manual. It moves the job to waiting, then the graph queues it when stage / needs allow. Otherwise the response is 409 Job is not waiting for manual play.
Request bodies are in the CI API.
POST /ci/runners body is { "name": "my laptop", "scope": "repository", "tags": [], "runUntagged": true, "protected": false, "paused": false }. scope is repository (default), project, or organization. PATCH changes tags, runUntagged, protected, and paused. Scope stays as it was at creation. The create response includes token once. POST /ci/variables body is { "key", "value", "masked", "protected", "file" }. Responses list the key and flags and never the value.
The runner process calls /api/ci/runner/... with Authorization: Bearer kfxr_…: heartbeat, claim, step start, log append, step complete, job complete. Claim returns one job, its steps, the clone URL, commit SHA, a read-only deploy key private key for that clone, predefined CI_ variables, repository variables (protected ones only on a protected branch), a ci_job token that can read that repository’s blobs, and a separate short-lived token that can only upload and create a static deployment in the project. CI_JOB_ID is that job’s id. Heartbeat cancelRequested is true only for the job that runner holds. Masked variable values are stripped from step logs when the runner appends them. POST .../ci/runs/:runId/jobs/:jobId/play starts a manual job.